From 9a0d6f80cb1bef2796ad6820479c7ccb990f9394 Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Thu, 14 May 2026 16:13:59 +0400 Subject: [PATCH] studio: API external provider support for chat (OpenAI, Mistral, Gemini, Cohere, Anthropic, OpenRouter, DeepSeek, custom providers) (#4706) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * studio: add external provider support for chat inference Adds the ability to connect to OpenAI, Mistral, Google, Cohere, Together, Fireworks, and Perplexity from the Studio chat interface. - Provider configs stored in SQLite (no API keys persisted) - RSA-2048 key pair generated at startup for client-side key encryption - httpx proxy client streams SSE responses in OpenAI-compatible format - New /api/providers routes: registry, CRUD, test, models - /v1/chat/completions routes to external provider when provider fields present - Integration test suite covering CRUD, connection, model listing, and inference - Frontend spec doc with full API contract * remove frontend spec doc from branch * fix auth fixture: handle forced password change on fresh install * fix tests: default port 8000, allow 400 for no-model-loaded * fix: update Cohere models to current (command-r retired Sept 2025) * feat: add OpenRouter as 8th provider * feat: add native Anthropic provider with Messages API translation * fix: correct Anthropic base URL and drop top_p (conflicts with temperature) * feat: add DeepSeek provider (deepseek-chat, deepseek-reasoner) * feat: rename google -> gemini, refresh model list to 2.5 series * feat: remove together, fireworks, perplexity providers * feat: multimodal image support for external providers - Add _build_external_messages() that preserves image_url parts for vision-capable providers instead of stripping them - Update _proxy_to_external_provider() to use new helper - Translate image_url content parts to Anthropic native image format in _stream_anthropic() - Add TestVisionInference pytest class (1x1 PNG smoke test) * test: use sloth photo URL for vision test, add Anthropic remote URL support * fix: update Mistral model to mistral-small-2506 * update mistral default model to mistral-large-2512 * fix gemini vision test: download image as base64 data URI instead of remote URL * add gemini-3-flash-preview as default gemini model * fix gemini truncated reply (max_tokens 16->64) and suppress GeneratorExit on client disconnect * increase vision test max_tokens to 215 * fix GeneratorExit: aclose stream generator before closing httpx client * fix httpcore GeneratorExit: explicitly aclose aiter_lines before response closes * fix duplicate [DONE] and suppress httpcore RuntimeError on Python 3.13 asyncgen cleanup * fix: call response.aclose() before lines_gen.aclose() to prevent httpcore RuntimeError on Python 3.13 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Potential fix for code scanning alert no. 36: Clear-text logging of sensitive information Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * review: add comments for manual iteration rationale, mask password in test print, clarify Anthropic URL/models support * perf: use shared module-level httpx client for connection pooling across requests * studio: add API provider UI and integrate wiring (#4737) * feat: expose external models in selector and chat settings * feat(chat): wire external providers to backend + RSA key flow - Fetch registry/configs; create/update/delete saved providers - Encrypt API keys (Web Crypto RSA-OAEP) for test/models/chat - External model selection + chat payload (provider_id/type, external_model, encrypted key, optional base URL) - Local storage for keys + provider list; small UX/copy and guardrails * add missing providers-api.ts file by Imagineer99 * fix: address PR review comments — system prompt visibility, retry loop, test logging * feat(studio): encrypt external provider API keys at rest in localStorage API keys for external providers (OpenAI, Mistral, etc.) were stored as plaintext in localStorage, vulnerable to browser extensions and XSS. Add password-derived AES-256-GCM encryption: on login the user's password is used via PBKDF2 (100k iterations, SHA-256) to derive an in-memory encryption key. API keys are encrypted before writing to localStorage and decrypted on read. The derived key is never persisted — cleared on logout, re-derived on next login. Legacy plaintext keys are transparently migrated on first access. Password changes re-encrypt all stored keys. No backend changes required — the existing RSA-OAEP transit encryption is unaffected. * fix: cast PBKDF2 salt to BufferSource for strict TypeScript lib types * fix: persist session password in sessionStorage to survive page refreshes * feat(studio): preserve image parts in external provider chat requests toOpenAIMessage() now returns multimodal content arrays (OpenAI vision format) when messages contain images, instead of always flattening to plain text. This enables vision-capable external providers (OpenAI, Gemini, Anthropic, etc.) to receive user images. The backend already handles image_url content parts in _build_external_messages(). * studio: fix external models selectable in chat-only mode (#4779) * fix: external models selectable in chat-only mode * fix: model selector tabs default to active model kind * Studio: API external provider registry + curated catalogs (HF/OpenRouter) and chat UX (#4787) * fix: external models selectable in chat-only mode * fix: model selector tabs default to active model kind * feat(studio): expand provider registry, curated catalogs, and chat UX - Add Hugging Face, Kimi, Qwen; remove Cohere; reorder registry - model_list_mode curated for HF/OpenRouter; lightweight /models check - API returns default models for curated providers; expose model_list_mode - Frontend: provider logos in model picker, providerType on external models - Chat providers dialog: curated vs remote flows, motion polish - Thread: LayoutGroup + composer motion alignment with app easing * fix(studio): disable Anthropic tool-calling flag and preselect curated defaults * feat(studio): add external provider logos and ApiProviderLogo helper * Studio: Polish API Providers dialog (#4899) * fix: lower verbage in API providers page * fix: fix(studio): tune API Providers dialog width with rem-based responsive caps * feat: add custom provider support (#4902) * fix: replace crypto.subtle with node-forge for HTTP compatibility crypto.subtle is only available in secure contexts (HTTPS/localhost), which breaks provider API key encryption when Studio is accessed over plain HTTP on remote GPU VMs. Switch to node-forge for RSA-OAEP and AES-256-GCM operations — same algorithms, works on any origin. * fix: store provider API keys as plaintext in localStorage Drop AES-256-GCM at-rest encryption for provider API keys. The session-password-derived encryption broke on auto-login via refresh token (password never captured), causing keys to silently vanish. API keys are still RSA-encrypted in transit via node-forge. At-rest encryption in localStorage added no real security since the decryption key also had to live client-side. Removes crypto-storage.ts, session password plumbing, and reEncryptAllKeys. * fix: use max_completion_tokens for OpenAI provider Newer OpenAI models (gpt-4o, gpt-5.x) reject the max_tokens param and require max_completion_tokens instead. Other providers still use max_tokens. * fix: skip empty assistant messages in external provider requests Some providers (Mistral) reject assistant messages with empty content. Filter them out when building the message list for external providers. * Update model-selector.tsx * Update model-selector.tsx * Update model-selector.tsx * Update chat-adapter.ts * Update chat-adapter.ts * Update chat-page.tsx * Update chat-settings-sheet.tsx * Update chat-settings-sheet.tsx * Update chat-settings-sheet.tsx * Update chat-providers-dialog.tsx * feat: polish providers settings form UI * style: polish provider row icon sizing and alignment * style: stabilize provider layout * style: add provider API key visibility toggle * fix: add provider render on empty list * studio/frontend: sync package-lock.json with package.json npm ci was failing because node-forge and @types/node-forge were declared in package.json but missing from the lockfile. Ran npm install to regenerate. * studio/backend: fix backend CI failures for providers router - test_desktop_auth: include providers_router in the routes stub so studio.backend.main imports cleanly under the monkeypatched module - test_providers_api: skip the whole module when STUDIO_TEST_PASSWORD is unset (it is an integration test against a live Studio server, same shape as the already-ignored test_studio_api.py) * studio/chat: drive ChatSettingsPanel from a per-provider capability map Replace the binary isExternalModel toggle in the sampling section with a provider-aware capability map. Each external provider type advertises which of top_k / min_p / repetition_penalty / presence_penalty its chat-completions API actually accepts, so the panel only renders the knobs that map onto the active provider's request body. Anthropic now exposes top_k; DeepSeek hides presence_penalty (deprecated in their docs); OpenRouter and custom providers continue to show every knob (OpenRouter drops unsupported server-side, custom assumes OpenAI-compat or a permissive vLLM/Ollama backend). Local models are unaffected — null capabilities means 'show everything'. chat-adapter.ts now forwards top_k / presence_penalty to the external proxy only when the active provider's capabilities permit it, so the request body matches what the UI shows. * studio/backend: forward top_k to Anthropic; filter OpenAI model list Two paired changes so the frontend capability map has matching backend behaviour: 1. ExternalProviderClient.stream_chat_completion now accepts top_k and forwards it to the Anthropic Messages body. OpenAI-compat providers (which all reject unknown sampling params) still receive only the fields they document. The proxy route in routes/inference.py passes payload.top_k through, so a UI request with top_k actually reaches Anthropic instead of being silently dropped at the boundary. 2. PROVIDER_REGISTRY['openai'] gains a model_id_allowlist regex that scopes the /models picker to current-gen ids (gpt-5.5 / gpt-5.4 / gpt-5.3 / gpt-4.5 / o3 families). The remote /v1/models listing otherwise returns dozens of historical snapshots, fine-tunes and non-chat models (embeddings, TTS, image, moderation) that we never want in the chat UI. default_models is refreshed to match. * studio/chat: relax presence_penalty to optional on OpenAIChatCompletionsRequest Followup to 1fbf445a — chat-adapter now omits presence_penalty for providers that do not accept it (Anthropic / DeepSeek), but the request type still required it as a non-optional number, breaking tsc. The backend pydantic model already defaults presence_penalty to 0, so making it optional client-side matches reality. * studio/backend: route OpenAI traffic through /v1/responses OpenAI's new flagship models (gpt-5.x) return 404 'This is not a chat model' on /v1/chat/completions and are only reachable via /v1/responses. Add a dedicated _stream_openai_responses path in ExternalProviderClient that: - Translates outbound messages into the Responses shape: system messages are folded into the top-level 'instructions' field, user/assistant messages become {role, content} items with input_text / input_image content parts (data URLs and https URLs both pass through). - Drops presence_penalty / top_k / frequency_penalty, none of which the Responses contract accepts. - Translates inbound SSE events back into OpenAI Chat Completions chunks so the frontend keeps a single SSE shape: response.output_text.delta -> delta chunk with content response.completed -> chunk with finish_reason='stop' response.incomplete -> chunk with finish_reason='length' response.failed / error -> propagated error SSE line Stream terminates with data: [DONE] (Responses emits this verbatim). stream_chat_completion dispatches all provider_type='openai' calls to this path; other OpenAI-compatible providers (mistral, gemini, etc.) continue to use /v1/chat/completions. Frontend provider-capabilities map updated to hide presence_penalty for OpenAI in the chat settings panel, matching the new request contract. Includes unit coverage in tests/test_openai_responses_translation.py exercising the request body translation, image-part rewriting, and SSE-to-chat-completions translation via httpx.MockTransport. * studio/chat: clamp external max_tokens to 32k to stay within provider caps The chat settings slider already capped maxTokens at 32768 for external models, but a value persisted from a prior local-model session (where the cap can be 128k+) was sent verbatim to the provider — Claude Opus returns 'max_tokens: 131072 > 128000' on requests like that, and other providers have stricter limits still. Expose EXTERNAL_MAX_OUTPUT_TOKENS from provider-capabilities (32k) and use it both for the slider max and as the clamp inside chat-adapter's external-request body. 32k sits below the tightest declared output limit across the providers we ship and well above what a typical chat reply needs; the local-model path is unaffected. * studio: drop temperature/top_p for OpenAI reasoning models gpt-5.x / o3 / gpt-4.5 are reasoning-class models served via /v1/responses, and reject temperature and top_p with 'Unsupported parameter' 400s. The OpenAI registry allowlist already scopes the picker to those families, so neither knob ever applies on this branch. - external_provider._stream_openai_responses no longer puts temperature or top_p in the request body (kept on the method signature for API symmetry with the other stream methods). - ProviderCapabilities gains temperature/topP flags; OpenAI sets both to false. ChatSettingsPanel hides the sliders for OpenAI so the user does not see inert controls. - chat-adapter omits temperature/top_p from the external request body when the active provider does not advertise them. - OpenAIChatCompletionsRequest type marks both as optional, matching the new chat-adapter shape. - test_responses_request_body_uses_input_and_instructions: assertions flipped to confirm temperature / top_p are absent from the body. * studio: stop forwarding top_k to Anthropic Claude 4.x (Opus / Sonnet / Haiku 4.x) returns 400 'top_k is deprecated for this model' on any request that includes top_k. It was always optional on the older 3.x line, so dropping it unconditionally for every Anthropic call is the simplest path — no per-model gate to maintain. - external_provider._stream_anthropic no longer adds top_k to the Messages body (kept on the method signature for API symmetry). - provider-capabilities sets anthropic.topK = false so the chat settings panel hides the Top K slider for Anthropic providers and chat-adapter does not send top_k in the external request. * studio: gate Anthropic top_k drop to Claude 4.7 only Previous commit (b5aa6ffd) dropped top_k for every Anthropic call, but only Claude 4.7 (Opus/Sonnet/Haiku) actually rejects it. 4.6, 4.5, and the 3.x line still accept top_k and use it as documented. Backend: _stream_anthropic matches the model id against ^claude-(opus|sonnet|haiku)-4-7(-|.|$) and only strips top_k when it hits. Every other Claude generation continues to receive the value from the chat settings panel. Frontend: anthropic.topK is restored to true so the Top K slider is visible again — the backend handles the per-model drop, and the 4.7 case is silent (request still succeeds without top_k). * chore: hide dated openai models in provider select * studio/providers: apply model_id_denylist when listing remote models The OpenAI registry entry gained a model_id_denylist regex matching dated snapshot ids (-YYYY-MM-DD) in 048d73bf, but the list-models route was never consulting it, so the snapshots still showed up alongside their canonical ids (gpt-5.5 and gpt-5.5-2026-04-23 both listed). Apply the denylist with .search() right after the allowlist filter so dated entries are dropped before the response is built. * studio/chat: seed registry default_models for remote providers in picker The Anthropic provider runs in remote model-list mode, so the picker started with an empty availableModels until the user clicked 'Load Models'. If that /api/providers/models call fails (e.g. the known transient decryption error during key rotation), the user sees no models at all — claude-haiku-4-5 in particular was missing from the dialog even though it is seeded in the registry. Always pre-populate availableModels with the registry's default_models when a provider type is selected (curated and remote alike), and have loadModels() return the union of defaults + the live /models response so registry-seeded ids are reachable regardless of what the provider's endpoint returns or whether the call succeeds at all. * studio/backend: diagnostic logging on provider key decryption Decryption failures currently log just 'Failed to decrypt API key: Decryption failed', which leaves no way to tell whether the cause is a stale public key in the browser, a corrupted ciphertext, an unexpected exception class, or a server-side keypair rotation. That's the gap the next reproduction needs to close. - key_exchange now publishes a short SHA256 fingerprint of the public key PEM. init_key_pair logs the fingerprint on generation and warns if it is ever called a second time (re-init silently invalidates every browser that cached the previous public key). - decrypt_api_key wraps both the base64 decode and the RSA decrypt in dedicated try/excepts that log exception type, ciphertext byte length (RSA-2048 should be exactly 256), input string length, and the current public-key fingerprint. - GET /api/providers/public-key returns the fingerprint alongside the PEM so the frontend can correlate a future encrypt-time fingerprint against the decrypt-time fingerprint and prove or rule out a keypair rotation as the cause. - The /test and /models route-level decrypt warnings now include the exception class name (alongside the existing message). * studio/providers: hide dated Anthropic snapshots from the model picker Anthropic's /v1/models returns dated snapshot ids (e.g. claude-3-5-sonnet-20241022, claude-3-5-haiku-20241022) alongside the canonical names users actually want to pick. Same intent as the OpenAI denylist added in 048d73bf, just a different date format — Anthropic uses -YYYYMMDD (no dashes) while OpenAI uses -YYYY-MM-DD. - Add model_id_denylist = re.compile(r'-\d{8}$') to the anthropic registry entry. The /api/providers/models route already applies any denylist after fetching, so dated ids drop out automatically. - Strip the dated 3.5 ids from default_models so the seeded picker no longer surfaces them; keep claude-opus-4-7 and the 4.5 family as the curated set. Net effect: the picker shows opus-4-7 / opus-4-5 / sonnet-4-5 / haiku-4-5 only, regardless of whether the remote /models call succeeds or fails. * fix: provider dialog and mistral short list * style: fix provider dialog curated list styling * fix: provider dialog curated model ids placeholder reference * style: rename Providers to Cloud and tighten dialog header spacing * UX: rename Providers to Cloud, remove header shortcut * studio/chat: normalize structured delta.content from reasoning providers Mistral's magistral (and similarly-shaped reasoning models) stream chat-completion deltas where choices[0].delta.content is an array of structured parts rather than a plain string, e.g. [{ type: 'text', text: '...' }, { type: 'thinking', thinking: '...' }] The accumulator did 'cumulativeText += delta', which coerced each part to '[object Object]' and produced output like '[object Object][object Object]...Hey there!'. Add extractDeltaText() to normalize delta.content before append: - string → returned as-is - array of parts → text/output_text parts contribute their .text or .content; thinking/reasoning parts are re-wrapped inline as ... so the downstream parseAssistantContent lifts them into a reasoning part the same way it does for providers that emit thinking inline. magistral keeps its thinking panel; no other provider's output shape changes. - unknown shapes → dropped rather than stringified, so a stray field cannot pollute the rendered chat with '[object Object]'. * Studio: restore Cloud icon shortcut in chat header Brings back the header chip that opens Settings -> Cloud (external providers) directly from the chat view. Same button as before the bf24e604 removal: single-mode only, opens useSettingsDialogStore on the 'connections' tab, tooltip 'API providers'. * studio/chat: strip trailing template literal from external provider streams Mistral's magistral occasionally appends a literal '${response}' token after its actual answer — likely a training-format artifact, since it keeps happening with an empty system prompt and only on that model. Apply a tight strip in the chat-adapter SSE accumulator: when the active provider is external, drop a trailing '${...}' template literal (with optional whitespace) from cumulativeText after each chunk. The regex anchors to end-of-string, so mid-stream fragments ('${re') remain untouched and only collapse once the closing brace arrives. Local-model output is unaffected. * studio/providers: scope Kimi picker to kimi-k2.6 / kimi-k2.5 Mirror what the live Kimi docs surface as the current models (https://platform.kimi.ai/docs/models). Everything else the remote /v1/models call returns — moonshot-v1-* legacy ids and dated k2 previews like kimi-k2-0711-preview — is filtered out. - default_models: ['kimi-k2.6', 'kimi-k2.5'] (was four legacy moonshot-v1 ids plus the dated k2 preview) - model_id_allowlist: ^kimi-k2\.[56]$ applied in the /api/providers/models route after the live fetch - doc-link comments point at platform.kimi.ai overview / models / list-models for the next refresh * studio: drop temperature/top_p for Kimi reasoning models Kimi k2.5/k2.6 are reasoning-class. The API locks temperature and top_p to fixed defaults and 400s on any other value with 'invalid temperature: only 1 is allowed for this model'. The frontend capability map already gated these knobs out of the external request body, but the OpenAI-compat path on the backend unconditionally re-adds them from the pydantic ChatCompletionRequest defaults (temperature=0.7 etc), so the gate was bypassed end-to-end. Add a generic body_omit hook on the provider registry that stream_chat_completion consults after building the body, and use it to strip temperature/top_p for Kimi. Frontend provider-capabilities flips kimi.temperature and kimi.topP to false so the sliders are hidden in the chat settings panel as well. * studio/providers: scope Gemini picker to current 3.x + *-latest aliases Google's /v1beta/openai/models returns dozens of historical, experimental, and non-chat ids that we never want in the chat UI. Cap the picker to the current curated set: - gemini-3.1-pro-preview - gemini-3.1-flash-lite - gemini-3-flash-preview - gemini-pro-latest - gemini-flash-latest - gemini-flash-lite-latest Default_models seeded with these, model_id_allowlist applied in the /api/providers/models route to drop anything else the live fetch returns. * studio/providers: switch Hugging Face to remote model listing Per the Inference Providers docs (https://huggingface.co/docs/inference-providers/index), GET https://router.huggingface.co/v1/models returns the full chat-model catalog across all providers, including per-provider metadata. The OpenAI-compatible endpoint we already use for chat completions accepts the same Bearer token, so flipping model_list_mode from 'curated' to 'remote' lets users discover models via the existing list_models() path without any new wiring. - model_list_mode: 'remote' (was 'curated') - default_models refreshed with current popular ids (gpt-oss-120b, DeepSeek-V3, Llama-3.3-70B, Qwen2.5-72B) so the picker still has a sensible seed if /v1/models fails - notes updated to reference the docs page and clarify the endpoint is chat-only * UX: chat cloud icon changed to model select signifier * studio/providers: org allowlist + count cap for HF Inference picker The HF /v1/models response is the full cross-provider catalog (hundreds of ids — community fine-tunes, mirrors, fp8 variants, dated snapshots). Scope the picker to the first-party org repos worth surfacing and cap the post-filter list. - model_id_allowlist matches the org prefixes openai/, deepseek-ai/, google/, meta-llama/, Qwen/, moonshotai/, mistralai/, zai-org/. Anything outside those orgs is dropped. - model_id_limit (new registry field) caps the post-filter list. The list-models route now slices [:limit] after allowlist/denylist; set to 15 for HF Inference. Other providers leave it unset and behave exactly as before. - default_models stays as the seed so the flagship ids users care about (gpt-oss-120b, DeepSeek-V3, Llama-3.3-70B, Qwen2.5-72B) are always reachable regardless of the API's response order. Dedup is already handled in loadModels() via Set, so no additional work needed there. * style: adjust cloud icon right margin with rem spacing * Studio: cloud openai reasoning level toggle (#5402) * feat: cloud openai reasoning level toggle * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: honor enable_thinking=false * fix: prevent local reasoning toggle regressions and align OpenAI effort levels * fix: isolate external OpenAI reasoning toggle state --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> * fix: clamp reasoning effort * fix: align OpenAI reasoning effort * fix: clear stale GGUF badge state * ui: new badge on cloud setting * fix: separate selected models from cached provider model list * Studio: anthropic effort by model family (#5412) * feat: external thinking control and Anthropic effort mapping * fix: anthropic thinking constraints and 4.6 max effort mapping * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: harden Anthropic thinking params and effort mapping --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * studio/backend: drop top_p from Anthropic body when thinking is enabled PR 5412 added body['top_p'] = max(0.95, min(top_p, 1.0)) inside the thinking branch of _stream_anthropic, but Anthropic returns 400 on extended/adaptive thinking when both temperature and top_p are set: invalid_request_error: temperature and top_p cannot both be specified for this model. Please use only one. (Observed on Claude Opus 4.6.) The contract for thinking-enabled requests is temperature=1 with neither top_p nor top_k allowed. Replace the body['top_p'] = ... line with body.pop('top_p', None). Defensive pop rather than a bare delete: the base body construction above does not currently set top_p, but a future edit that adds it would silently reintroduce the regression. * studio/chat: force reasoningEnabled=true on local reasoning-effort models Followup to PR 5402 / 5412. The model-status refresh path in use-chat-model-runtime carried reasoningEnabled forward verbatim for every reasoning-capable model. That left one observable edge case: 1. user picks an external model that supports Off (gpt-5.x, Claude 4.x), clicks Off — store sets reasoningEnabled=false 2. user switches back to a local reasoning-effort model (gpt-oss / Harmony-style) which does NOT support Off 3. composer's effectiveReasoningEnabled override paints the UI as 'Think: ' (on) 4. chat-adapter sees reasoningEnabled=false on the local branch and sends '{}', so the backend's _request_reasoning_kwargs returns None and the Harmony template falls back to its own default effort instead of the displayed level Mirror the composer's override in the store on load: for local reasoning-effort models (where supportsReasoningOff is false), force reasoningEnabled=true so the store and the UI agree on every send. Other reasoning styles still inherit prior state — only the reasoning-effort family changes. * studio/backend: align Anthropic thinking with the extended-thinking docs Two compliance fixes against https://platform.claude.com/docs/en/build-with-claude/extended-thinking 1. Adaptive-mode effort field shape The docs spell adaptive thinking as: {'thinking': {'type': 'adaptive'}, 'effort': {'type': ''}} We had been sending the legacy 'output_config: {effort: }' shape, which Anthropic appears to silently ignore — adaptive ran at the server default effort regardless of the user's selection. Rename to 'effort: {type: }'. 2. thinking_delta event translation The Messages-API streams reasoning content as content_block_delta events with delta.type == 'thinking_delta', which our SSE loop was dropping entirely. On Claude 4.5/4.6 with display=summarized (the default), the user would see the answer text but never the reasoning panel. Wrap thinking_delta.thinking as inline ... chunks (same pattern as the OpenAI Responses path) so the frontend's parseAssistantContent lifts it into the reasoning channel. The closer fires on the first text_delta transition, on content_block_stop for the thinking block, on message_delta, and on message_stop — whichever arrives first — so no model path can leak an unclosed into chat output. signature_delta events are left as no-ops; they carry verification metadata, not user-visible content. Adds test_anthropic_thinking_translation.py with httpx.MockTransport coverage of: effort shape on adaptive (Claude 4.6), budget_tokens shape on manual (Claude 4.5), thinking_delta wrapping with signature suppression, and thinking-only turns (display=omitted on Opus 4.7). * studio/backend: revert Anthropic adaptive effort to output_config nesting The previous commit (0a664df4) moved the adaptive-thinking effort field to a top-level 'effort: {type: }' based on a misread of the docs page. The actual Messages API schema nests it under output_config: thinking: optional ThinkingConfigParam ({type: 'adaptive'}) output_config: optional OutputConfig effort: optional 'low' | 'medium' | 'high' | 'xhigh' | 'max' Sending the top-level field produced: 400 invalid_request_error: effort: Extra inputs are not permitted Restore the body to: body['thinking'] = {'type': 'adaptive'} body['output_config'] = {'effort': effort} This was the shape PR 5412 originally shipped (and the author validated against live APIs). My 'compliance fix' was a regression. The companion thinking_delta SSE translation added in 0a664df4 stays — that part WAS missing from the previous shape and is unchanged by this revert. Test pinning the body shape flipped to assert output_config.effort, top-level effort is asserted absent. * studio/backend: opt in to summarized thinking display on adaptive Per the adaptive-thinking docs, the 'display' field on the thinking config defaults to 'omitted' on Claude Opus 4.7 (and Mythos Preview). With 'omitted' the API still emits a thinking content block, but its 'thinking' field is empty — only the signature_delta arrives. Our SSE handler would then surface a stray '' for the empty block and the reasoning panel would stay blank for the entire response. Set 'display': 'summarized' explicitly on the adaptive thinking config so Opus 4.7 emits thinking_delta events the same way Opus 4.6 / Sonnet 4.6 do (where 'summarized' is the default, making the explicit setting a no-op there). The manual-thinking branch (Claude 4.5) is unaffected — its default is also 'summarized', and we have no reason to override it. * studio/backend: log Anthropic SSE event counts for thinking diagnostics Reports of 'no reasoning panel content on Anthropic' have two distinct causes that produce the same symptom: 1. Anthropic streamed thinking_delta events but our frontend dropped them somewhere on the rendering side. 2. Anthropic did not emit thinking_delta at all (adaptive mode can skip thinking for simple prompts even with effort=high, and display=summarized only re-enables the *content* — it does not force thinking to happen). Tally each event type for the duration of one stream and log the counts in the finally branch, so the next 'no reasoning content' report shows immediately whether thinking_delta was even on the wire. Zero counts → upstream (model/effort/prompt choice). Non-zero counts → triage moves to chat-adapter / parse-assistant -content / the reasoning component. * studio/backend: route external_provider logs through structlog The studio backend wires structlog as the active logger (via LogConfig.setup_logging at main.py:262), but external_provider.py was using stdlib logging.getLogger(__name__) for every diagnostic. The stdlib root logger defaults to WARNING with no handlers attached, so plain logger.info('...') and logger.debug('...') from this module were being silently dropped — including the 'Proxying chat completion to ' and the new 'Anthropic stream event counts' lines. Only WARNING/ERROR survived (via the implicit fallthrough that the user actually observed when an Anthropic call 400'd). Switch the module-level logger to structlog.get_logger(__name__), matching the routes/providers.py and routes/inference.py pattern. All existing call sites use printf-style positional args, which structlog accepts unchanged — no other edits needed. * studio/backend: disable read timeout on SSE streams to external providers Anthropic Opus 4.7 (adaptive thinking) and OpenAI gpt-5.x (/v1/responses) can pause for tens of seconds between bytes while the model is internally reasoning. httpx's read timeout is the *gap* between successive reads, not a wall clock on the whole request — so the shared 120s default was cutting streams mid-response: log: Anthropic stream event counts (... text_delta: 11) Read timeout from anthropic (eleven text deltas in, no content_block_stop, no message_stop) Add a separate _stream_timeout on ExternalProviderClient with read = None (no gap timeout) and the same 10s / 120s connect/write/ pool bounds, then use it at the three SSE streaming call sites: default OpenAI-compat chat completions, _stream_anthropic, and _stream_openai_responses. Non-streaming call sites (chat_completion, list_models, verify_models_endpoint_lightweight) keep self._timeout because a stuck non-streaming response should still fail fast. * studio/backend: log outbound Anthropic request shape for thinking debug After bumping to Xhigh effort the user still saw zero thinking_delta events and only one content_block_start, meaning Anthropic Opus 4.7 opened no thinking block at all. Per the effort docs that should be impossible — Xhigh always thinks. Two open hypotheses: 1. Our adaptive branch is not wiring output_config.effort onto the outbound body for this code path (regex miss, frontend never propagated reasoning_effort, etc). 2. Anthropic is silently accepting output_config as an unknown field and falling back to high default effort regardless. Add a single-line structlog INFO right before the stream POST that echoes the keys actually present on the body (thinking, output_config, temperature, presence of top_p / top_k, max_tokens). Messages are deliberately excluded to keep PII out of the log. With this in place the next 'no thinking on 4.7 at Xhigh' report shows immediately whether we sent the effort knob — separating client bug from provider behaviour. * studio/chat: surface delta.reasoning_content from Kimi / DeepSeek thinking Kimi (kimi-k2.6, kimi-k2-thinking) and DeepSeek's reasoner stream their thinking content via a separate top-level field on the chat-completion delta — choices[0].delta.reasoning_content — rather than as a structured part inside delta.content. Per Kimi docs: In streaming output (stream=True), the reasoning_content field will always appear before the content field. Our chat-adapter SSE loop only read delta.content (via extractDeltaText), so the entire reasoning channel from these providers was being silently dropped — kimi-k2.6 thinks by default yet the chat UI showed no reasoning panel. In the adapter: - Read both delta.content and delta.reasoning_content per chunk - When reasoning_content arrives, open a block in cumulativeText (mirrors how the backend wraps Anthropic thinking_delta and OpenAI Responses reasoning summaries) - When content arrives after reasoning, close first - On stream end, force-close any still-open so parseAssistantContent can lift it into a reasoning part cleanly Anthropic and OpenAI Responses paths are unaffected — they already wrap as on the backend and never set reasoning_content. * studio: Kimi thinking toggle + 16k max_tokens floor Two coordinated changes so Kimi's thinking is user-controllable and the response budget meets the docs' floor. Toggle (frontend + backend): - getExternalReasoningCapabilities now handles provider=='kimi': kimi-k2.6 -> reasoning_style=enable_thinking, reasoningOff allowed kimi-k2-thinking -> always on (reasoningAlwaysOn=true, no off) kimi-k2.5 (and anything else) -> no reasoning controls - chat-adapter already forwards enable_thinking on the enable_thinking-style branch, so the user toggle reaches the backend without additional wiring there. - external_provider stream_chat_completion now translates the boolean into Kimi's wire shape on the default OAI-compat path: enable_thinking=True -> body['thinking'] = {type: enabled, keep: all} enable_thinking=False -> body['thinking'] = {type: disabled} kimi-k2-thinking ignores the toggle so the API never gets a disabled value it would reject. Other providers on the same path are unaffected (gated on provider_type == 'kimi'). Max tokens floor: - New EXTERNAL_MIN_OUTPUT_TOKENS_BY_PROVIDER table and getExternalMinOutputTokens helper. Kimi entry = 16000 per docs: 'Set max_tokens >= 16,000 to ensure the full reasoning_content and final content can be returned without truncation.' - chat-adapter clamps the outbound max_tokens to min(max(stored, providerMin), EXTERNAL_MAX_OUTPUT_TOKENS), so a stored value of 4096 still becomes 16000 when sending to Kimi (other providers unaffected, min stays effectively 64). - chat-settings-sheet's Max Tokens slider min mirrors the same floor when an external Kimi model is selected, so the slider cannot show a value lower than what we'd actually send. - chat-page threads activeExternalProviderType down to the panel. * fix: stabilize external reasoning controls for Anthropic 4.6 and OpenAI o3 normalize Anthropic 4.6 reasoning effort handling by accepting max as an alias and mapping it to xhigh, while keeping Sonnet/Opus 4.6 in default model suggestions. broaden reasoning effort typing across backend/frontend and migrate persisted max selections to xhigh for compatibility. remove reasoning.summary=\"auto\" from OpenAI /v1/responses payloads to avoid o3 eligibility/gating errors. tighten provider model filtering to hide retired gpt-5.3 IDs and add exact/prefix filtering support in provider routes. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: add openrouter/free + full reasoning passthrough on OpenRouter Four-layer wire-up so the OpenRouter free-router model (which picks a free model at random per request, filtered by needed capabilities) shows up in the picker and its reasoning channel surfaces in the chat UI. Registry: - providers.py: openrouter/free seeded at the top of openrouter default_models. Curated list, so picker shows it immediately. Frontend capability map: - provider-capabilities.ts: getExternalReasoningCapabilities now treats openrouter as enable_thinking style with off support. The Think dropdown appears for every OpenRouter model; the gateway silently no-ops the parameter for models that do not reason, so surfacing one toggle on every model is safe. Backend reasoning passthrough: - external_provider.py stream_chat_completion (default OAI-compat branch): for provider_type=='openrouter', translate the request: reasoning_effort in {low,medium,high} -> body['reasoning'] = {'effort': } enable_thinking=True -> body['reasoning'] = {'enabled': True} enable_thinking=False -> body['reasoning'] = {'enabled': False} Matches the documented shape at https://openrouter.ai/docs/guides/best-practices/reasoning-tokens with effort and max_tokens mutually exclusive. Frontend SSE reader: - chat-adapter.ts: OpenRouter streams reasoning as a third shape we did not handle yet: delta.reasoning_details is an array of parts like {type: 'reasoning.text', text: '...'}. Pull text from every part, merge with the existing delta.reasoning_content channel used by Kimi/DeepSeek, and feed the combined string through the same ... wrap path so parseAssistantContent lifts it into the reasoning panel. Anthropic/OpenAI Responses paths already wrap on the backend, so they never set this field — no cross-provider interference. * studio/backend: surface OpenRouter SSE errors and router-chosen model in logs The frontend showed 'Provider returned error' for some openrouter/free requests with nothing on the backend side to triage from — the existing 4xx error log only fires when the upstream returns a non-200 status code, but OpenRouter (and most OAI-compat providers) return 200 OK and emit the actual failure as an SSE error event mid-stream, which our default-path stream loop forwarded verbatim without logging. Best-effort diagnostics on the default OpenAI-compat stream path: - Peek at every `data:` line in the inner forward loop, parse JSON best-effort (silently skip on failure so nothing is dropped). - Count event types: delta / error / done. - On any chunk containing an `error` field, emit a structlog WARNING with the provider type and the error payload — same trail the user would otherwise have to dig out of browser devtools. - Latch the first non-empty `chunk.model` field. OpenRouter reports the router-picked underlying model there per request, so the finally-block summary log shows which free model handled the call. In the finally block: 'openrouter stream complete (model=openrouter/free, chosen=google/gemini-2.5-flash, events={delta: 47, done: 1})' Zero overhead for non-error streams (a json.loads per chunk + dict-key lookups). The structlog logger is already configured at INFO; ERROR and WARNING surface in JSON logs without further setup. Hoists `import json as _json` to module top so the default path can reuse it; the existing in-function imports in _stream_anthropic and _stream_openai_responses are now redundant but harmless. * studio/chat: show router-picked model after 'openrouter/free:' in chip When the user picks openrouter/free, the gateway routes each request to a different underlying free model. Until now there was no way to tell which one actually replied without reading the backend logs. Surface the picked model in the active-model chip: - chat-runtime-store gains lastOpenRouterChosenModel: string|null plus a setter. Reset on every model switch unless the user stays on openrouter/free. - chat-adapter SSE loop latches chunk.model into the store on every chunk whose top-level model differs from openrouter/free, gated on the active checkpoint being openrouter/free under an OpenRouter provider. - chat-page externalModels useMemo appends : to the display name for the openrouter/free option when the store has a value, so ModelSelector renders e.g. 'openrouter/free:google/gemini-2.5-flash' in the chip. Other models unaffected. - Model-switch callback in chat-page clears the cached value when the user moves to any model other than openrouter/free, so the chip never shows a stale suffix from a previous session. * studio/chat: shorten openrouter/free chip to openrouter: The full display name in use was: openrouter/free:inclusionai/ring-2.6-1t-20260508:free The `:free` suffix on the underlying id already conveys 'free model', which made the leading `/free` on the router id redundant, and the `inclusionai/` org prefix was just noise crowding the chip. Trim both. Now the chip renders as: openrouter:ring-2.6-1t-20260508:free Strictly a display change in chat-page externalModels useMemo — the backend wire id stays `openrouter/free`, the runtime store still caches the full `inclusionai/...:free` value, and the model-switch clearing logic is unchanged. * studio/providers: switch OpenRouter to remote listing with org allowlist + cap Same shape as Hugging Face Inference. The curated list had only four entries; remote listing fetches OpenRouter's full ~300-model catalog via /v1/models and the new allowlist + limit scope it back down to a usable picker. - model_list_mode: remote (was curated) - model_id_allowlist matches the prefixes: openrouter | openai | anthropic | google | meta-llama | qwen | mistralai | deepseek | moonshotai | inclusionai | zai-org | z-ai Anything outside drops out. - model_id_limit: 20 — first 20 post-filter matches from the live fetch; default_models stays seeded so the most useful canonical ids are always visible regardless of API response order. - default_models seed extended from 4 to 6 (openrouter/free, openai/gpt-4o, anthropic/claude-sonnet-4-5, google/gemini-2.5-flash, mistralai/mistral-large-2411, deepseek/deepseek-r1). openrouter/free remains the first entry, so the dialog's loadModels() union-merge (registryDefaults first, then remote, deduped via Set) keeps it at the top of the picker. * feat: external mistral thinking toggle * studio/chat: fix TS2540 by replacing readonly ContentPart instead of mutating The ContentPart type from @assistant-ui/react marks `text` as readonly, so the coalesce-adjacent-same-type-part optimization in parseAssistantContent failed the tsc build with: parse-assistant-content.ts(15,10): error TS2540: Cannot assign to 'text' because it is a read-only property. parse-assistant-content.ts(25,10): error TS2540: ... This broke npm run build, the Studio installer's `building frontend...` step, and every downstream CI job that runs against an installed Studio (Mac/Windows/Linux variants of Studio API CI, GGUF CI, UI CI, Tauri CI, Wheel CI). Replace the last element with a fresh merged object instead of mutating its `text` field. Same allocation profile as the previous path (one object swap per merge), type-safe under the readonly declaration. Behaviour unchanged. * studio/backend: restore summary='auto' on OpenAI Responses reasoning body A recent refactor dropped the `summary: 'auto'` field from the reasoning config we send to /v1/responses. Without it OpenAI does not emit reasoning summary events on most reasoning models, which means our SSE handler has no to wrap and the chat reasoning panel stays blank for any gpt-5.x / o3 response. The expected wire shape is: body['reasoning'] = {'effort': '', 'summary': 'auto'} Two backend tests pin this: - test_responses_reasoning_effort_included_when_requested (high) - test_responses_reasoning_effort_xhigh_passthrough (xhigh) Both were failing with AssertionError because the produced body omitted `summary: auto`. Restore the field. Skip it only for the explicit "off" case (effort: 'none'), where summaries serve no purpose. The enable_thinking=True fallback (no explicit effort) also pairs medium effort with summary='auto' so that branch produces reasoning text too. * chat: external reasoning, OpenRouter curation, Think toggle fixes * fix: opus and sonnet 4.6 xhigh --> max * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Co-authored-by: imagineer99 Co-authored-by: Daniel Han --- .../core/inference/external_provider.py | 1238 +++++++++++++++ studio/backend/core/inference/key_exchange.py | 127 ++ studio/backend/core/inference/providers.py | 287 ++++ studio/backend/main.py | 7 + studio/backend/models/inference.py | 28 +- studio/backend/models/providers.py | 128 ++ studio/backend/requirements/studio.txt | 2 + studio/backend/routes/__init__.py | 2 + studio/backend/routes/inference.py | 164 ++ studio/backend/routes/providers.py | 338 ++++ studio/backend/storage/providers_db.py | 153 ++ .../test_anthropic_thinking_translation.py | 404 +++++ studio/backend/tests/test_desktop_auth.py | 1 + .../test_openai_responses_translation.py | 432 ++++++ studio/backend/tests/test_providers_api.py | 609 ++++++++ studio/frontend/package-lock.json | 21 + studio/frontend/package.json | 2 + .../public/provider-logos/anthropic.svg | 6 + .../public/provider-logos/deepseek.svg | 14 + .../frontend/public/provider-logos/gemini.svg | 72 + .../public/provider-logos/huggingface.svg | 8 + .../frontend/public/provider-logos/kimi.jpg | Bin 0 -> 15554 bytes .../public/provider-logos/misc/meta.svg | 19 + .../public/provider-logos/misc/microsoft.svg | 1 + .../public/provider-logos/misc/minimax.png | Bin 0 -> 8665 bytes .../public/provider-logos/misc/nvidia.svg | 1 + .../public/provider-logos/misc/perplexity.png | Bin 0 -> 6632 bytes .../public/provider-logos/misc/xai.svg | 1 + .../public/provider-logos/misc/z-ai.svg | 215 +++ .../public/provider-logos/mistral.svg | 19 + .../frontend/public/provider-logos/openai.svg | 5 + .../public/provider-logos/openrouter.svg | 1 + .../frontend/public/provider-logos/qwen.png | Bin 0 -> 117172 bytes .../assistant-ui/model-selector.tsx | 246 ++- .../assistant-ui/model-selector/types.ts | 9 +- .../src/components/assistant-ui/thread.tsx | 125 +- .../src/features/chat/api-provider-logo.tsx | 68 + .../src/features/chat/api/chat-adapter.ts | 604 ++++++-- .../src/features/chat/api/providers-api.ts | 230 +++ .../frontend/src/features/chat/chat-page.tsx | 240 ++- .../features/chat/chat-providers-dialog.tsx | 1361 +++++++++++++++++ .../src/features/chat/chat-settings-sheet.tsx | 199 ++- .../src/features/chat/external-providers.ts | 230 +++ .../chat/hooks/use-chat-model-runtime.ts | 61 +- .../features/chat/provider-capabilities.ts | 448 ++++++ .../src/features/chat/shared-composer.tsx | 173 ++- .../chat/stores/chat-runtime-store.ts | 40 +- .../chat/stores/external-providers-store.ts | 24 + .../frontend/src/features/chat/types/api.ts | 37 +- .../chat/utils/parse-assistant-content.ts | 22 +- .../src/features/settings/settings-dialog.tsx | 26 +- .../settings/stores/settings-dialog-store.ts | 15 +- .../settings/tabs/connections-tab.tsx | 17 + tests/test_import_fixes_drift.py | 4 +- 54 files changed, 8209 insertions(+), 275 deletions(-) create mode 100644 studio/backend/core/inference/external_provider.py create mode 100644 studio/backend/core/inference/key_exchange.py create mode 100644 studio/backend/core/inference/providers.py create mode 100644 studio/backend/models/providers.py create mode 100644 studio/backend/routes/providers.py create mode 100644 studio/backend/storage/providers_db.py create mode 100644 studio/backend/tests/test_anthropic_thinking_translation.py create mode 100644 studio/backend/tests/test_openai_responses_translation.py create mode 100644 studio/backend/tests/test_providers_api.py create mode 100644 studio/frontend/public/provider-logos/anthropic.svg create mode 100644 studio/frontend/public/provider-logos/deepseek.svg create mode 100644 studio/frontend/public/provider-logos/gemini.svg create mode 100644 studio/frontend/public/provider-logos/huggingface.svg create mode 100644 studio/frontend/public/provider-logos/kimi.jpg create mode 100644 studio/frontend/public/provider-logos/misc/meta.svg create mode 100644 studio/frontend/public/provider-logos/misc/microsoft.svg create mode 100644 studio/frontend/public/provider-logos/misc/minimax.png create mode 100644 studio/frontend/public/provider-logos/misc/nvidia.svg create mode 100644 studio/frontend/public/provider-logos/misc/perplexity.png create mode 100644 studio/frontend/public/provider-logos/misc/xai.svg create mode 100644 studio/frontend/public/provider-logos/misc/z-ai.svg create mode 100644 studio/frontend/public/provider-logos/mistral.svg create mode 100644 studio/frontend/public/provider-logos/openai.svg create mode 100644 studio/frontend/public/provider-logos/openrouter.svg create mode 100644 studio/frontend/public/provider-logos/qwen.png create mode 100644 studio/frontend/src/features/chat/api-provider-logo.tsx create mode 100644 studio/frontend/src/features/chat/api/providers-api.ts create mode 100644 studio/frontend/src/features/chat/chat-providers-dialog.tsx create mode 100644 studio/frontend/src/features/chat/external-providers.ts create mode 100644 studio/frontend/src/features/chat/provider-capabilities.ts create mode 100644 studio/frontend/src/features/chat/stores/external-providers-store.ts create mode 100644 studio/frontend/src/features/settings/tabs/connections-tab.tsx diff --git a/studio/backend/core/inference/external_provider.py b/studio/backend/core/inference/external_provider.py new file mode 100644 index 0000000000..f5b67eef70 --- /dev/null +++ b/studio/backend/core/inference/external_provider.py @@ -0,0 +1,1238 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Async HTTP client for proxying chat completions to external LLM providers. + +Most registry providers expose OpenAI-compatible /v1/chat/completions endpoints; +Anthropic uses native Messages API with translation in this client. +""" + +import json as _json +import re +from typing import Any, AsyncGenerator, Literal, NamedTuple, Optional + +import httpx +import structlog + +# Use structlog so INFO-level diagnostics actually surface in the +# studio backend's JSON log stream. The stdlib root logger defaults to +# WARNING and is not configured with handlers, so plain +# `logging.getLogger(__name__).info(...)` was being silently dropped — +# only WARNING/ERROR made it through (because they bypassed the root +# level threshold via uvicorn's stderr capture). All existing call +# sites use printf-style positional args, which structlog accepts. +logger = structlog.get_logger(__name__) + +# Claude 4.7 (Opus/Sonnet/Haiku) deprecated top_k and returns 400 +# "top_k is deprecated for this model" when it is set. 3.x and 4.5/4.6 +# still accept it. Match the 4-7 line specifically so we keep the knob +# live on every other Claude generation. +_ANTHROPIC_TOP_K_DEPRECATED = re.compile(r"^claude-(?:opus|sonnet|haiku)-4-7(?:[-.]|$)") + + +class _AnthropicThinkingSpec(NamedTuple): + prefixes: tuple[str, ...] + kind: Literal["adaptive", "manual"] + efforts: tuple[str, ...] + + +_ANTHROPIC_THINKING_SPECS = ( + _AnthropicThinkingSpec( + prefixes = ("claude-opus-4-7",), + kind = "adaptive", + efforts = ("none", "low", "medium", "high", "xhigh"), + ), + _AnthropicThinkingSpec( + prefixes = ("claude-opus-4-6", "claude-sonnet-4-6"), + kind = "adaptive", + efforts = ("none", "low", "medium", "high", "xhigh", "max"), + ), + _AnthropicThinkingSpec( + prefixes = ("claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5"), + kind = "manual", + efforts = ("none", "low", "medium", "high"), + ), +) + + +def _anthropic_thinking_spec(model: str) -> Optional[_AnthropicThinkingSpec]: + for spec in _ANTHROPIC_THINKING_SPECS: + if model.startswith(spec.prefixes): + return spec + return None + + +class _MistralThinkingSpec(NamedTuple): + models: tuple[str, ...] + style: Literal["prompt_mode", "reasoning_effort", "disabled"] + efforts: tuple[str, ...] = () + + +_MISTRAL_THINKING_SPECS = ( + _MistralThinkingSpec( + models = ("magistral-medium-latest",), + style = "prompt_mode", + ), + _MistralThinkingSpec( + models = ("mistral-small-latest", "mistral-vibe-cli-latest"), + style = "reasoning_effort", + efforts = ("none", "high"), + ), +) + +_OPENROUTER_MANDATORY_REASONING_MODELS = frozenset( + { + "~google/gemini-pro-latest", + "baidu/cobuddy:free", + "inclusionai/ring-2.6-1t:free", + "deepseek/deepseek-r1", + } +) + + +def _mistral_thinking_spec(model: str) -> _MistralThinkingSpec: + for spec in _MISTRAL_THINKING_SPECS: + if model in spec.models: + return spec + return _MistralThinkingSpec(models = (), style = "disabled") + + +def _apply_mistral_reasoning_controls( + body: dict[str, Any], + model: str, + enable_thinking: Optional[bool], + reasoning_effort: Optional[str], +) -> None: + """ + Translate generic reasoning controls into Mistral's model-specific shape. + + Current contract: + - magistral-medium-latest: baseline (no extra field) or + `prompt_mode="reasoning"` for the explicit reasoning mode. + - mistral-small-latest / mistral-vibe-cli-latest: + `reasoning_effort` in {"none", "high"}. + - all other tested Mistral models: no reasoning/thinking params. + """ + model_for_matching = model.rsplit("/", 1)[-1].strip().lower() + spec = _mistral_thinking_spec(model_for_matching) + body.pop("prompt_mode", None) + body.pop("reasoning_effort", None) + + if spec.style == "prompt_mode": + # Magistral baseline is already reasoning-capable. The explicit + # prompt_mode path is only used for the "high" UI selection. + if enable_thinking is True or reasoning_effort == "high": + body["prompt_mode"] = "reasoning" + return + + if spec.style == "reasoning_effort": + if reasoning_effort in spec.efforts: + body["reasoning_effort"] = reasoning_effort + elif enable_thinking is False: + body["reasoning_effort"] = "none" + elif enable_thinking is True: + body["reasoning_effort"] = "high" + + +# Shared client reused across all requests for HTTP connection pooling. +# Auth headers and timeouts are passed per-request, so a single client +# handles every provider without storing credentials. +_http_client = httpx.AsyncClient() + + +class ExternalProviderClient: + """Async proxy for OpenAI-compatible external LLM APIs.""" + + def __init__( + self, + provider_type: str, + base_url: str, + api_key: str, + timeout: float = 120.0, + ): + self.provider_type = provider_type + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self._timeout = httpx.Timeout(timeout, connect = 10.0) + # Separate timeout for SSE streams: reasoning-heavy providers + # (Anthropic Opus 4.7 with adaptive thinking, OpenAI gpt-5.x via + # /v1/responses) can pause for tens of seconds between bytes + # while the model is internally thinking. httpx's read timeout is + # the *gap* between successive reads, not a wall clock — so + # disabling it lets long thinks complete without cutting the + # stream prematurely. connect/write/pool keep the 10s / 120s + # bounds so genuine network failures still surface. + self._stream_timeout = httpx.Timeout(timeout, connect = 10.0, read = None) + + def _auth_headers(self) -> dict[str, str]: + """Build authentication headers using the provider's registry config.""" + from core.inference.providers import get_provider_info + + provider_info = get_provider_info(self.provider_type) or {} + auth_header = provider_info.get("auth_header", "Authorization") + auth_prefix = provider_info.get("auth_prefix", "Bearer ") + + headers = { + "Content-Type": "application/json", + auth_header: f"{auth_prefix}{self.api_key}", + } + # Merge any provider-specific extra headers (e.g. anthropic-version, OpenRouter attribution) + headers.update(provider_info.get("extra_headers", {})) + return headers + + def _is_openai_compatible(self) -> bool: + """Return False for providers that need request/response translation (e.g. Anthropic).""" + from core.inference.providers import get_provider_info + + info = get_provider_info(self.provider_type) or {} + return info.get("openai_compatible", True) + + async def stream_chat_completion( + self, + messages: list[dict[str, Any]], + model: str, + temperature: float = 0.7, + top_p: float = 0.95, + max_tokens: Optional[int] = None, + presence_penalty: float = 0.0, + top_k: Optional[int] = None, + enable_thinking: Optional[bool] = None, + reasoning_effort: Optional[str] = None, + stream: bool = True, + ) -> AsyncGenerator[str, None]: + """ + Yield OpenAI-format SSE lines from the external provider. + + For OpenAI-compatible providers, lines are forwarded verbatim. + For Anthropic, the native Messages API SSE is translated to OpenAI format. + + ``top_k`` and ``presence_penalty`` are forwarded only when the caller + supplies a value the provider accepts — the frontend's + provider-capability map already filters these per provider, so we + treat them as opt-in here. + """ + if not self._is_openai_compatible(): + async for line in self._stream_anthropic( + messages, + model, + temperature, + top_p, + max_tokens, + top_k, + enable_thinking, + reasoning_effort, + ): + yield line + return + + # OpenAI moved their flagship models (gpt-5.x) off /v1/chat/completions + # — those endpoints return 404 with "This is not a chat model" for the + # new families. Route all OpenAI traffic through /v1/responses instead; + # we translate the Responses SSE back into Chat Completions chunks so + # the frontend stays endpoint-agnostic. + if self.provider_type == "openai": + async for line in self._stream_openai_responses( + messages, + model, + temperature, + top_p, + max_tokens, + enable_thinking, + reasoning_effort, + ): + yield line + return + + body: dict[str, Any] = { + "model": model, + "messages": messages, + "stream": stream, + "temperature": temperature, + "top_p": top_p, + "presence_penalty": presence_penalty, + } + if max_tokens is not None: + # OpenAI newer models (gpt-4o, gpt-5.x) reject max_tokens + if self.provider_type == "openai": + body["max_completion_tokens"] = max_tokens + else: + body["max_tokens"] = max_tokens + + # Strip body fields a provider's registry entry declares unusable — + # reasoning-class models that lock these to fixed defaults (e.g. + # Kimi k2.5/k2.6 only accept temperature=1, top_p=1) 400 otherwise. + # The frontend capability map already hides the matching sliders; + # this is the matching guard for the pydantic default that the + # route layer would otherwise still fill in. + from core.inference.providers import get_provider_info + + provider_info = get_provider_info(self.provider_type) or {} + for field in provider_info.get("body_omit", ()): + body.pop(field, None) + + # Kimi (kimi-k2.6, kimi-k2-thinking) accepts a boolean thinking toggle + # via a top-level `thinking` field (the docs show it nested under + # extra_body, but that is an OpenAI Python SDK convention; on the + # wire it merges into the request body). + # - kimi-k2.6 defaults to thinking enabled; clients can pass + # {"type": "disabled"} to suppress it. + # - kimi-k2-thinking is always on; we never send disabled there. + # `keep: all` retains every thinking chunk through the stream, which + # is what we need so our frontend can wrap reasoning_content into + # the chat reasoning panel. + if self.provider_type == "kimi" and enable_thinking is not None: + if model == "kimi-k2-thinking": + # Always on; ignore client toggle to avoid an API-level reject. + pass + elif enable_thinking: + body["thinking"] = {"type": "enabled", "keep": "all"} + else: + body["thinking"] = {"type": "disabled"} + elif self.provider_type == "mistral": + _apply_mistral_reasoning_controls( + body, model, enable_thinking, reasoning_effort + ) + + # OpenRouter exposes a unified `reasoning` parameter on every + # chat-completion request — the gateway routes it to whichever + # underlying model actually supports reasoning, and silently + # no-ops for ones that don't. Documented at + # https://openrouter.ai/docs/guides/best-practices/reasoning-tokens + # Shape: `reasoning: {enabled?: bool, effort?: low|medium|high, + # max_tokens?: N, exclude?: bool}` with effort and max_tokens + # mutually exclusive. We forward either an effort level (when + # the user picked one) or a bare {enabled: true}. A small set of + # known routes rejects explicit disable with 400 ("Reasoning is + # mandatory for this endpoint ..."), so only those omit "off". + if self.provider_type == "openrouter": + normalized_or_model = model.strip().lower() + if reasoning_effort in ("low", "medium", "high"): + body["reasoning"] = {"effort": reasoning_effort} + elif enable_thinking is True: + body["reasoning"] = {"enabled": True} + elif enable_thinking is False: + if normalized_or_model in _OPENROUTER_MANDATORY_REASONING_MODELS: + body.pop("reasoning", None) + else: + body["reasoning"] = {"enabled": False} + + url = f"{self.base_url}/chat/completions" + logger.info( + "Proxying chat completion to %s (provider=%s, model=%s)", + url, + self.provider_type, + model, + ) + + try: + async with _http_client.stream( + "POST", + url, + json = body, + headers = self._auth_headers(), + timeout = self._stream_timeout, + ) as response: + if response.status_code != 200: + error_body = await response.aread() + error_text = error_body.decode("utf-8", errors = "replace") + logger.error( + "External provider returned %d: %s", + response.status_code, + error_text[:500], + ) + yield _error_sse_line( + response.status_code, error_text, self.provider_type + ) + return + + # NOTE: manual __anext__ loop instead of `async for` is intentional. + # On Python 3.13 + httpcore 1.0.x, `async for` auto-calls aclose() on + # early exit (break/return/GeneratorExit) BEFORE our finally block runs. + # That propagates GeneratorExit into PoolByteStream.__aiter__() while it + # calls `await self.aclose()` inside `with AsyncShieldCancellation()`, + # triggering "RuntimeError: async generator ignored GeneratorExit". + # Fix: call response.aclose() FIRST (sets PoolByteStream._closed=True), + # then lines_gen.aclose() is a no-op and GeneratorExit re-raises cleanly. + lines_gen = response.aiter_lines().__aiter__() + # Best-effort diagnostics for the default OAI-compat path. Without + # this, OpenRouter mid-stream errors (200 OK + error event in the + # SSE body) and OpenRouter-router model selection were invisible + # in the backend logs — the user only saw "Provider returned + # error" in the UI with no trail on the server side. + event_counts: dict[str, int] = {} + chosen_model: Optional[str] = None + try: + while True: + try: + line = await lines_gen.__anext__() + except StopAsyncIteration: + break + if not line.strip(): + continue + if line.startswith("data:"): + data_str = line[len("data:") :].strip() + if data_str == "[DONE]": + event_counts["done"] = event_counts.get("done", 0) + 1 + elif data_str: + try: + parsed = _json.loads(data_str) + except Exception: + parsed = None + if isinstance(parsed, dict): + # Mid-stream provider error event. OpenRouter + # in particular returns 200 then surfaces the + # actual failure as an SSE error event. + if "error" in parsed: + event_counts["error"] = ( + event_counts.get("error", 0) + 1 + ) + logger.warning( + "%s SSE error event: %s", + self.provider_type, + parsed.get("error"), + ) + else: + event_counts["delta"] = ( + event_counts.get("delta", 0) + 1 + ) + # OpenRouter (and most OAI-compat providers) + # report the underlying model that handled + # the request in every chunk's `model` field. + # Latch the first non-empty value so the + # router-picked model surfaces in logs and + # is available to the proxy caller. + if chosen_model is None and isinstance( + parsed.get("model"), str + ): + chosen_model = parsed["model"] + yield line + except GeneratorExit: + await response.aclose() # set PoolByteStream._closed=True FIRST + await lines_gen.aclose() # now safe — aclose() is a no-op + raise + finally: + logger.info( + "%s stream complete (model=%s, chosen=%s, events=%s)", + self.provider_type, + model, + chosen_model, + event_counts, + ) + await response.aclose() + await lines_gen.aclose() + + except httpx.ConnectError as exc: + logger.error("Connection error to %s: %s", self.provider_type, exc) + yield _error_sse_line( + 502, + f"Failed to connect to {self.provider_type}: {exc}", + self.provider_type, + ) + except httpx.ReadTimeout as exc: + logger.error("Read timeout from %s: %s", self.provider_type, exc) + yield _error_sse_line( + 504, + f"Timeout waiting for {self.provider_type} response", + self.provider_type, + ) + except httpx.HTTPError as exc: + logger.error("HTTP error from %s: %s", self.provider_type, exc) + yield _error_sse_line( + 502, + f"Error communicating with {self.provider_type}: {exc}", + self.provider_type, + ) + + async def _stream_anthropic( + self, + messages: list[dict[str, Any]], + model: str, + temperature: float, + top_p: float, + max_tokens: Optional[int], + top_k: Optional[int] = None, + enable_thinking: Optional[bool] = None, + reasoning_effort: Optional[str] = None, + ) -> AsyncGenerator[str, None]: + """ + Call the Anthropic Messages API and translate its SSE to OpenAI format. + + Anthropic SSE event types: + content_block_delta → OpenAI chunk with delta.content + message_delta → OpenAI chunk with finish_reason + message_stop → data: [DONE] + (all others skipped) + """ + import json as _json + + # Extract system prompt and translate image_url parts to Anthropic format + system: Optional[str] = None + filtered: list[dict[str, Any]] = [] + for msg in messages: + if msg.get("role") == "system": + content = msg.get("content", "") + system = ( + content + if isinstance(content, str) + else "\n".join( + p["text"] for p in content if p.get("type") == "text" + ) + ) + continue + + content = msg.get("content") + if isinstance(content, list): + # Translate OpenAI image_url parts → Anthropic native image format + anthropic_parts: list[dict[str, Any]] = [] + for part in content: + if part.get("type") == "text": + anthropic_parts.append({"type": "text", "text": part["text"]}) + elif part.get("type") == "image_url": + url = part.get("image_url", {}).get("url", "") + if url.startswith("data:"): + # data:image/png;base64, → split header and data + header, _, b64data = url.partition(",") + media_type = ( + header.split(";")[0].replace("data:", "") + or "image/jpeg" + ) + anthropic_parts.append( + { + "type": "image", + "source": { + "type": "base64", + "media_type": media_type, + "data": b64data, + }, + } + ) + else: + # Remote URL — Anthropic supports url source type natively. + # See: https://docs.anthropic.com/en/docs/build-with-claude/vision#url-based-images + anthropic_parts.append( + { + "type": "image", + "source": { + "type": "url", + "url": url, + }, + } + ) + filtered.append({"role": msg["role"], "content": anthropic_parts}) + else: + filtered.append(msg) + + body: dict[str, Any] = { + "model": model, + "messages": filtered, + "max_tokens": max_tokens or 1024, # required by Anthropic + "temperature": temperature, + "stream": True, + } + # top_k is deprecated on Claude 4.7 (Opus/Sonnet/Haiku) — the API + # returns 400 "top_k is deprecated for this model" when it is set. + # 3.x and 4.5/4.6 still accept it, so gate strictly on the 4.7 ids. + if ( + top_k is not None + and top_k > 0 + and not _ANTHROPIC_TOP_K_DEPRECATED.match(model) + ): + body["top_k"] = top_k + if system: + body["system"] = system + thinking_spec = _anthropic_thinking_spec(model) + allowed_efforts = ( + thinking_spec.efforts + if thinking_spec + else ("none", "low", "medium", "high") + ) + effort = reasoning_effort if reasoning_effort in allowed_efforts else None + # Claude 4.6 Opus/Sonnet accept top-tier adaptive effort as "max" only; + # "xhigh" is rejected (supported on Claude 4.7). Map our shared "xhigh" + # semantic to "max" for 4.6 outbound requests while still accepting + # both in ``allowed_efforts`` for persisted / cross-provider UI state. + if effort == "xhigh" and model.startswith( + ("claude-opus-4-6", "claude-sonnet-4-6") + ): + effort = "max" + if effort is None: + if enable_thinking is False: + effort = "none" + elif enable_thinking is True: + effort = "medium" + # Normalize one semantic Thinking control into Anthropic's two model-era + # APIs: adaptive effort on Claude 4.6/4.7, manual budget_tokens on 4.5. + if effort and effort != "none": + # Anthropic rejects top_k whenever thinking is enabled. + body.pop("top_k", None) + # Anthropic requires temperature=1 whenever thinking is enabled, + # AND forbids top_p in the same request: setting both produces + # "temperature and top_p cannot both be specified for this + # model. Please use only one." + # The base body never sets top_p, but pop defensively in case + # an upstream edit ever adds it before this branch runs. + body["temperature"] = 1 + body.pop("top_p", None) + if thinking_spec and thinking_spec.kind == "adaptive": + # `display` defaults to "omitted" on Claude Opus 4.7 (per the + # adaptive-thinking docs) — without an explicit opt-in the + # API emits an empty thinking block plus a signature_delta, + # so our SSE handler would surface a stray + # and the reasoning panel would stay blank. Force + # "summarized" so 4.7 streams thinking_delta events like + # 4.6 does. On 4.6 / Sonnet 4.6 this is the default, so + # setting it explicitly is harmless. + body["thinking"] = {"type": "adaptive", "display": "summarized"} + # Per the Messages API reference, the effort knob for + # adaptive thinking lives under `output_config.effort` — + # NOT as a top-level field. Sending `effort: ...` directly + # produces a 400 "effort: Extra inputs are not permitted". + # Allowed values: low | medium | high | xhigh | max. See: + # https://platform.claude.com/docs/en/api/messages + body["output_config"] = {"effort": effort} + elif thinking_spec and thinking_spec.kind == "manual": + budget_tokens = {"low": 1024, "medium": 2048, "high": 4096}[effort] + body["thinking"] = { + "type": "enabled", + "budget_tokens": budget_tokens, + } + # Anthropic requires max_tokens to be strictly greater than + # thinking.budget_tokens on the manual-thinking path. + if body.get("max_tokens", 0) <= budget_tokens: + body["max_tokens"] = budget_tokens + 1024 + + url = f"{self.base_url}/messages" + completion_id = f"chatcmpl-anthropic-{model.replace('/', '-')}" + + # Log the outgoing config keys (not the messages themselves) so we + # can prove which thinking/effort fields actually reached the wire. + # If Anthropic skips reasoning despite a configured effort, this + # tells us whether we sent the field or dropped it on the floor. + logger.info( + "Anthropic request shape (model=%s, has_thinking=%s, thinking=%s, " + "output_config=%s, temperature=%s, has_top_p=%s, has_top_k=%s, " + "max_tokens=%s)", + model, + "thinking" in body, + body.get("thinking"), + body.get("output_config"), + body.get("temperature"), + "top_p" in body, + "top_k" in body, + body.get("max_tokens"), + ) + + _finish_reason_map = { + "end_turn": "stop", + "max_tokens": "length", + "stop_sequence": "stop", + } + + logger.info("Proxying Anthropic Messages API to %s (model=%s)", url, model) + + try: + async with _http_client.stream( + "POST", + url, + json = body, + headers = self._auth_headers(), + timeout = self._stream_timeout, + ) as response: + if response.status_code != 200: + error_body = await response.aread() + error_text = error_body.decode("utf-8", errors = "replace") + logger.error( + "Anthropic returned %d: %s", + response.status_code, + error_text[:500], + ) + yield _error_sse_line( + response.status_code, error_text, self.provider_type + ) + return + + # NOTE: same manual __anext__ loop as stream_chat_completion — see comment there. + lines_gen = response.aiter_lines().__aiter__() + thinking_open = False + # Diagnostic counters for the next time the user reports + # "no thinking content" — distinguishes "Anthropic never sent + # thinking_delta" from "frontend didn't render the chunks". + event_counts: dict[str, int] = {} + + def _content_chunk(text: str) -> str: + chunk = { + "id": completion_id, + "object": "chat.completion.chunk", + "choices": [ + { + "index": 0, + "delta": {"content": text}, + "finish_reason": None, + } + ], + } + return f"data: {_json.dumps(chunk)}" + + try: + while True: + try: + line = await lines_gen.__anext__() + except StopAsyncIteration: + break + if not line or line.startswith("event:"): + continue + if not line.startswith("data:"): + continue + + data_str = line[len("data:") :].strip() + if not data_str: + continue + + try: + event = _json.loads(data_str) + except _json.JSONDecodeError: + continue + + event_type = event.get("type") + if event_type == "content_block_delta": + delta_kind = (event.get("delta") or {}).get("type") + key = f"{event_type}:{delta_kind}" + else: + key = event_type or "" + event_counts[key] = event_counts.get(key, 0) + 1 + + if event_type == "content_block_delta": + delta = event.get("delta", {}) + delta_type = delta.get("type") + if delta_type == "thinking_delta": + # Anthropic streams extended-thinking content as + # thinking_delta events on a separate content + # block. Wrap as inline ... so + # the frontend's parseAssistantContent lifts it + # into the reasoning panel — same pattern as + # the OpenAI Responses path. + thinking_text = delta.get("thinking", "") + if thinking_text: + if not thinking_open: + thinking_text = f"{thinking_text}" + thinking_open = True + yield _content_chunk(thinking_text) + elif delta_type == "text_delta": + # First text after a thinking block closes the + # tag we opened above. Anthropic emits + # a content_block_stop between blocks, but + # closing on the text_delta transition is more + # forgiving if events arrive out of order. + if thinking_open: + yield _content_chunk("") + thinking_open = False + text = delta.get("text", "") + if text: + yield _content_chunk(text) + # signature_delta and any other delta types are + # intentionally skipped — they carry trust / + # verification metadata, not user-visible content. + + elif event_type == "content_block_stop": + # Close the tag when the thinking block + # ends, in case no text_delta follows (e.g. + # display=omitted on Claude 4.7, or thinking-only + # turns). + if thinking_open: + yield _content_chunk("") + thinking_open = False + + elif event_type == "message_delta": + stop_reason = event.get("delta", {}).get("stop_reason") + if stop_reason: + if thinking_open: + yield _content_chunk("") + thinking_open = False + chunk = { + "id": completion_id, + "object": "chat.completion.chunk", + "choices": [ + { + "index": 0, + "delta": {}, + "finish_reason": _finish_reason_map.get( + stop_reason, "stop" + ), + } + ], + } + yield f"data: {_json.dumps(chunk)}" + + elif event_type == "message_stop": + if thinking_open: + yield _content_chunk("") + thinking_open = False + yield "data: [DONE]" + await ( + response.aclose() + ) # set PoolByteStream._closed=True FIRST + break + except GeneratorExit: + await response.aclose() # set PoolByteStream._closed=True FIRST + await lines_gen.aclose() # now safe — aclose() is a no-op + raise + finally: + # Surface per-event-type counts so reports of "no + # reasoning panel content" can be triaged at a glance: + # zero `content_block_delta:thinking_delta` entries + # means Anthropic skipped thinking for this prompt + # (adaptive can choose to); non-zero means thinking + # arrived and we wrapped it — any visual gap is then + # on the frontend. + logger.info( + "Anthropic stream event counts (model=%s): %s", + model, + event_counts, + ) + await response.aclose() + await lines_gen.aclose() + + except httpx.ConnectError as exc: + logger.error("Connection error to %s: %s", self.provider_type, exc) + yield _error_sse_line( + 502, + f"Failed to connect to {self.provider_type}: {exc}", + self.provider_type, + ) + except httpx.ReadTimeout as exc: + logger.error("Read timeout from %s: %s", self.provider_type, exc) + yield _error_sse_line( + 504, + f"Timeout waiting for {self.provider_type} response", + self.provider_type, + ) + except httpx.HTTPError as exc: + logger.error("HTTP error from %s: %s", self.provider_type, exc) + yield _error_sse_line( + 502, + f"Error communicating with {self.provider_type}: {exc}", + self.provider_type, + ) + + async def _stream_openai_responses( + self, + messages: list[dict[str, Any]], + model: str, + temperature: float, + top_p: float, + max_tokens: Optional[int], + enable_thinking: Optional[bool], + reasoning_effort: Optional[str], + ) -> AsyncGenerator[str, None]: + """ + Call OpenAI's /v1/responses endpoint and translate its SSE stream back + into OpenAI Chat Completions chunk format. + + The Responses API uses a different request shape (``input`` instead of + ``messages``, ``instructions`` for system prompts, ``max_output_tokens`` + for the budget) and emits event-typed SSE frames (e.g. + ``response.output_text.delta``) rather than chat-completion chunks. + ``presence_penalty`` / ``top_k`` are not part of the Responses contract + and are dropped here intentionally. + """ + import json as _json + + # Split system messages out into a single `instructions` string and + # translate user/assistant messages into the Responses input shape. + instructions_parts: list[str] = [] + input_items: list[dict[str, Any]] = [] + for msg in messages: + role = msg.get("role") + content = msg.get("content", "") + + if role == "system": + if isinstance(content, str): + if content: + instructions_parts.append(content) + elif isinstance(content, list): + for part in content: + if part.get("type") == "text" and part.get("text"): + instructions_parts.append(part["text"]) + continue + + if isinstance(content, str): + input_items.append({"role": role, "content": content}) + continue + + if isinstance(content, list): + translated_parts: list[dict[str, Any]] = [] + for part in content: + part_type = part.get("type") + if part_type == "text": + translated_parts.append( + {"type": "input_text", "text": part.get("text", "")} + ) + elif part_type == "image_url": + url = part.get("image_url", {}).get("url", "") + if url: + # Responses takes image_url as a flat string (both + # https:// URLs and data: URLs are accepted). + translated_parts.append( + {"type": "input_image", "image_url": url} + ) + if translated_parts: + input_items.append({"role": role, "content": translated_parts}) + + # NOTE: gpt-5.x / o3 / gpt-4.5 are reasoning-class models. They reject + # temperature and top_p with `Unsupported parameter` 400s on + # /v1/responses (and on /v1/chat/completions for the same families). + # The PROVIDER_REGISTRY['openai'] model_id_allowlist already scopes + # the picker to those families, so we never need to send sampling + # knobs here. ``reasoning.effort`` defaults to "medium" server-side + # if omitted — surface it in a future commit if a knob is wanted. + del temperature, top_p # explicit drop — params are accepted for + # API symmetry with the other stream methods but not forwarded. + + body: dict[str, Any] = { + "model": model, + "input": input_items, + "stream": True, + } + # `summary: "auto"` is what makes /v1/responses emit reasoning + # summary events — without it OpenAI returns no thinking text on + # most reasoning models, the SSE handler has no + # to wrap, and the chat reasoning panel stays blank. Always pair + # an explicit effort with summary except for the explicit "off" + # case (effort: "none"), where summaries are pointless. + if reasoning_effort in ( + "minimal", + "low", + "medium", + "high", + "max", + "xhigh", + ): + body["reasoning"] = {"effort": reasoning_effort, "summary": "auto"} + elif reasoning_effort == "none" or enable_thinking is False: + body["reasoning"] = {"effort": "none"} + elif enable_thinking is True: + body["reasoning"] = {"effort": "medium", "summary": "auto"} + if instructions_parts: + body["instructions"] = "\n\n".join(instructions_parts) + if max_tokens is not None: + body["max_output_tokens"] = max_tokens + + url = f"{self.base_url}/responses" + completion_id = f"chatcmpl-openai-{model.replace('/', '-')}" + + logger.info("Proxying OpenAI Responses API to %s (model=%s)", url, model) + + try: + async with _http_client.stream( + "POST", + url, + json = body, + headers = self._auth_headers(), + timeout = self._stream_timeout, + ) as response: + if response.status_code != 200: + error_body = await response.aread() + error_text = error_body.decode("utf-8", errors = "replace") + logger.error( + "OpenAI Responses returned %d: %s", + response.status_code, + error_text[:500], + ) + yield _error_sse_line( + response.status_code, error_text, self.provider_type + ) + return + + # NOTE: same manual __anext__ loop as stream_chat_completion — + # see comment there for the GeneratorExit / aclose ordering. + lines_gen = response.aiter_lines().__aiter__() + done_emitted = False + reasoning_open = False + reasoning_emitted = False + + def _extract_reasoning_text(payload: Any) -> str: + if payload is None: + return "" + if isinstance(payload, str): + return payload + if isinstance(payload, list): + out: list[str] = [] + for item in payload: + text = _extract_reasoning_text(item) + if text: + out.append(text) + return "".join(out) + if isinstance(payload, dict): + # OpenAI responses may carry reasoning summaries in + # different envelope fields across event variants. + for key in ("text", "delta", "content", "summary"): + if key in payload: + text = _extract_reasoning_text(payload.get(key)) + if text: + return text + if payload.get("type") == "summary_text": + return _extract_reasoning_text(payload.get("text")) + return "" + + def _chunk_with_text(text: str) -> str: + chunk = { + "id": completion_id, + "object": "chat.completion.chunk", + "choices": [ + { + "index": 0, + "delta": {"content": text}, + "finish_reason": None, + } + ], + } + return f"data: {_json.dumps(chunk)}" + + try: + while True: + try: + line = await lines_gen.__anext__() + except StopAsyncIteration: + break + if not line or line.startswith("event:"): + continue + if not line.startswith("data:"): + continue + + data_str = line[len("data:") :].strip() + if not data_str: + continue + if data_str == "[DONE]": + if not done_emitted: + yield "data: [DONE]" + done_emitted = True + break + + try: + event = _json.loads(data_str) + except _json.JSONDecodeError: + continue + + event_type = event.get("type") + + if event_type == "response.output_text.delta": + delta_text = event.get("delta", "") + if delta_text: + if reasoning_open: + yield _chunk_with_text("") + reasoning_open = False + yield _chunk_with_text(delta_text) + + elif event_type == "response.output_item.done": + item = event.get("item", {}) + if ( + isinstance(item, dict) + and item.get("type") == "reasoning" + ): + summary_text = _extract_reasoning_text( + item.get("summary") + ) + if summary_text and not reasoning_emitted: + if not reasoning_open: + summary_text = f"{summary_text}" + reasoning_open = True + yield _chunk_with_text(summary_text) + reasoning_emitted = True + + elif isinstance(event_type, str) and "reasoning" in event_type: + reasoning_delta = _extract_reasoning_text(event) + if reasoning_delta: + if not reasoning_open: + reasoning_delta = f"{reasoning_delta}" + reasoning_open = True + yield _chunk_with_text(reasoning_delta) + reasoning_emitted = True + + elif event_type == "response.completed": + if reasoning_open: + yield _chunk_with_text("") + reasoning_open = False + chunk = { + "id": completion_id, + "object": "chat.completion.chunk", + "choices": [ + { + "index": 0, + "delta": {}, + "finish_reason": "stop", + } + ], + } + yield f"data: {_json.dumps(chunk)}" + + elif event_type == "response.incomplete": + if reasoning_open: + yield _chunk_with_text("") + reasoning_open = False + chunk = { + "id": completion_id, + "object": "chat.completion.chunk", + "choices": [ + { + "index": 0, + "delta": {}, + "finish_reason": "length", + } + ], + } + yield f"data: {_json.dumps(chunk)}" + + elif event_type in ("response.failed", "error"): + # Surface the failure to the client; let the + # outer route emit [DONE] as part of its cleanup. + error_payload = event.get("response", {}).get( + "error", {} + ) or { + "message": event.get("message", "Unknown error"), + "code": event.get("code"), + } + yield _error_sse_line( + 502, + _json.dumps(error_payload), + self.provider_type, + ) + break + except GeneratorExit: + await response.aclose() + await lines_gen.aclose() + raise + finally: + await response.aclose() + await lines_gen.aclose() + + except httpx.ConnectError as exc: + logger.error("Connection error to %s: %s", self.provider_type, exc) + yield _error_sse_line( + 502, + f"Failed to connect to {self.provider_type}: {exc}", + self.provider_type, + ) + except httpx.ReadTimeout as exc: + logger.error("Read timeout from %s: %s", self.provider_type, exc) + yield _error_sse_line( + 504, + f"Timeout waiting for {self.provider_type} response", + self.provider_type, + ) + except httpx.HTTPError as exc: + logger.error("HTTP error from %s: %s", self.provider_type, exc) + yield _error_sse_line( + 502, + f"Error communicating with {self.provider_type}: {exc}", + self.provider_type, + ) + + async def chat_completion( + self, + messages: list[dict[str, Any]], + model: str, + temperature: float = 0.7, + top_p: float = 0.95, + max_tokens: Optional[int] = None, + presence_penalty: float = 0.0, + ) -> dict[str, Any]: + """Non-streaming chat completion. Returns the full response dict. + + Note: only valid for OpenAI-compatible providers. Anthropic requires its + own Messages API; use stream_chat_completion (with stream=False) instead + if a non-streaming Anthropic path is needed in the future. + """ + body: dict[str, Any] = { + "model": model, + "messages": messages, + "stream": False, + "temperature": temperature, + "top_p": top_p, + "presence_penalty": presence_penalty, + } + if max_tokens is not None: + if self.provider_type == "openai": + body["max_completion_tokens"] = max_tokens + else: + body["max_tokens"] = max_tokens + + response = await _http_client.post( + f"{self.base_url}/chat/completions", + json = body, + headers = self._auth_headers(), + timeout = self._timeout, + ) + response.raise_for_status() + return response.json() + + async def list_models(self) -> list[dict[str, Any]]: + """ + Call GET /models on the provider to discover available models. + + Returns a list of model dicts with at least 'id' and optionally + 'created', 'owned_by', etc. + + All supported providers expose a /models endpoint: + - OpenAI-compatible: standard {"data": [...]} response + - Anthropic: https://api.anthropic.com/v1/models — same {"data": [...]} shape + """ + try: + response = await _http_client.get( + f"{self.base_url}/models", + headers = self._auth_headers(), + timeout = self._timeout, + ) + response.raise_for_status() + data = response.json() + # OpenAI format: {"data": [{"id": "...", ...}, ...]} + models = data.get("data", []) + return models + except httpx.HTTPError as exc: + logger.error("Failed to list models from %s: %s", self.provider_type, exc) + raise + + async def verify_models_endpoint_lightweight(self) -> None: + """ + Confirm GET /models returns 200 without buffering the full response body. + + Used for providers with enormous catalogs (e.g. OpenRouter, Hugging Face router) + where downloading the full JSON would be prohibitive. + """ + url = f"{self.base_url}/models" + try: + async with _http_client.stream( + "GET", + url, + headers = self._auth_headers(), + timeout = self._timeout, + ) as response: + if response.status_code != 200: + response.raise_for_status() + async for _chunk in response.aiter_bytes(chunk_size = 2048): + break + except httpx.HTTPError as exc: + logger.error( + "Lightweight /models check failed for %s: %s", + self.provider_type, + exc, + ) + raise + + async def close(self) -> None: + """No-op — the underlying client is shared across requests.""" + + +def _error_sse_line(status_code: int, message: str, provider_type: str) -> str: + """Format an error as an SSE data line in OpenAI error format.""" + import json + + error_obj = { + "error": { + "message": message, + "type": "provider_error", + "code": str(status_code), + "provider": provider_type, + } + } + return f"data: {json.dumps(error_obj)}" diff --git a/studio/backend/core/inference/key_exchange.py b/studio/backend/core/inference/key_exchange.py new file mode 100644 index 0000000000..f43bb16cf6 --- /dev/null +++ b/studio/backend/core/inference/key_exchange.py @@ -0,0 +1,127 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +RSA key pair for encrypting API keys in transit. + +The frontend encrypts API keys with the server's public key before +including them in requests. The backend decrypts with its private key +before forwarding to external providers. + +The key pair is generated at server startup and lives only in memory — +it is regenerated on each restart. The frontend fetches the public key +via GET /api/providers/public-key on load. +""" + +import base64 +import hashlib +import logging + +from cryptography.hazmat.primitives.asymmetric import rsa, padding +from cryptography.hazmat.primitives import serialization, hashes + +logger = logging.getLogger(__name__) + +_private_key: rsa.RSAPrivateKey | None = None +_public_key_pem: str | None = None +_public_key_fingerprint: str | None = None + + +def _compute_fingerprint(pem: str) -> str: + """SHA256 of the PEM bytes, truncated for log compactness.""" + return hashlib.sha256(pem.encode("utf-8")).hexdigest()[:16] + + +def init_key_pair() -> None: + """Generate an RSA-2048 key pair. Called once at server startup.""" + global _private_key, _public_key_pem, _public_key_fingerprint + if _private_key is not None: + # Re-entry is suspicious — every fresh keypair invalidates all + # in-flight ciphertext encrypted against the previous public key. + # Log loudly so a regression that calls init twice is visible. + logger.warning( + "init_key_pair called again — replacing existing RSA keypair " + "(previous fingerprint=%s). Any frontend that cached the old " + "public key will start hitting decryption failures.", + _public_key_fingerprint, + ) + _private_key = rsa.generate_private_key( + public_exponent = 65537, + key_size = 2048, + ) + _public_key_pem = ( + _private_key.public_key() + .public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode("utf-8") + ) + _public_key_fingerprint = _compute_fingerprint(_public_key_pem) + logger.info( + "RSA key pair generated for API key encryption (fingerprint=%s)", + _public_key_fingerprint, + ) + + +def get_public_key_fingerprint() -> str | None: + """Short SHA256 of the current public key PEM; None before init.""" + return _public_key_fingerprint + + +def get_public_key_pem() -> str: + """Return the PEM-encoded public key for the frontend.""" + if _public_key_pem is None: + raise RuntimeError("Key pair not initialized. Call init_key_pair() first.") + return _public_key_pem + + +def decrypt_api_key(encrypted_b64: str) -> str: + """ + Decrypt an API key that was encrypted with the public key. + + Args: + encrypted_b64: Base64-encoded RSA-OAEP ciphertext. + + Returns: + The plaintext API key string. + """ + if _private_key is None: + raise RuntimeError("Key pair not initialized. Call init_key_pair() first.") + + try: + ciphertext = base64.b64decode(encrypted_b64) + except Exception as exc: + logger.warning( + "decrypt_api_key: base64 decode failed (input_len=%d, fingerprint=%s): %s: %s", + len(encrypted_b64), + _public_key_fingerprint, + type(exc).__name__, + exc, + ) + raise + + try: + plaintext = _private_key.decrypt( + ciphertext, + padding.OAEP( + mgf = padding.MGF1(algorithm = hashes.SHA256()), + algorithm = hashes.SHA256(), + label = None, + ), + ) + except Exception as exc: + # Surface enough state to distinguish key mismatch (wrong public key + # used on encrypt) from a padding/algo mismatch or corrupted bytes. + # Expected ciphertext length for RSA-2048 is exactly 256 bytes. + logger.warning( + "decrypt_api_key: RSA decrypt failed (ciphertext_len=%d, expected=256, " + "fingerprint=%s, exc=%s): %s", + len(ciphertext), + _public_key_fingerprint, + type(exc).__name__, + exc, + ) + raise + + return plaintext.decode("utf-8") diff --git a/studio/backend/core/inference/providers.py b/studio/backend/core/inference/providers.py new file mode 100644 index 0000000000..4b6d7d6b17 --- /dev/null +++ b/studio/backend/core/inference/providers.py @@ -0,0 +1,287 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Static registry of supported external LLM providers. + +All providers expose OpenAI-compatible /v1/chat/completions endpoints +with Bearer token authentication and SSE streaming support. +""" + +import re +from typing import Any + +PROVIDER_REGISTRY: dict[str, dict[str, Any]] = { + "openai": { + "display_name": "OpenAI", + "base_url": "https://api.openai.com/v1", + "default_models": [ + "gpt-5.5", + "gpt-5.4", + "gpt-5.4-mini", + "o3", + ], + "supports_streaming": True, + "supports_vision": True, + "supports_tool_calling": True, + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + # Keep the model picker scoped to the current generation. The remote + # /v1/models listing returns dozens of historical snapshots, fine-tunes + # and non-chat models (embeddings, TTS, image, moderation) that we + # never want to surface in the chat UI. Filtering here so backend + # is the single source of truth. + "model_id_allowlist": re.compile(r"^(gpt-5\.[345]|gpt-4\.5|o3)(?:[-.]|$)"), + # Hide dated snapshots and the retired plain gpt-5.3 id. + "model_id_denylist": re.compile(r"^(gpt-5\.3)$|-\d{4}-\d{2}-\d{2}$"), + }, + "anthropic": { + "display_name": "Anthropic", + "base_url": "https://api.anthropic.com/v1", + "default_models": [ + "claude-opus-4-7", + "claude-opus-4-6", + "claude-sonnet-4-6", + "claude-opus-4-5", + "claude-sonnet-4-5", + "claude-haiku-4-5", + ], + # Anthropic /v1/models returns dated snapshot ids alongside the + # canonical names (e.g. claude-3-5-sonnet-20241022). Hide the + # YYYYMMDD-suffixed variants from the picker — same intent as the + # OpenAI denylist, just a different date format (no dashes between + # year/month/day). + "model_id_denylist": re.compile(r"-\d{8}$"), + "supports_streaming": True, + "supports_vision": True, + "supports_tool_calling": False, + "auth_header": "x-api-key", + "auth_prefix": "", + "extra_headers": { + "anthropic-version": "2023-06-01", + }, + "openai_compatible": False, + "notes": "Native Anthropic Messages API. Uses x-api-key header and /v1/messages endpoint with SSE translation.", + }, + "gemini": { + "display_name": "Google Gemini", + "base_url": "https://generativelanguage.googleapis.com/v1beta/openai", + # Curated lineup — Google's /v1beta/openai/models returns dozens + # of historical / experimental / embedding ids. Cap to the current + # 3.x family plus the rolling `*-latest` aliases. + "default_models": [ + "gemini-3.1-pro-preview", + "gemini-3.1-flash-lite", + "gemini-3-flash-preview", + "gemini-pro-latest", + "gemini-flash-latest", + "gemini-flash-lite-latest", + ], + "supports_streaming": True, + "supports_vision": True, + "supports_tool_calling": True, + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "notes": "OpenAI-compatible endpoint. API key from https://aistudio.google.com/apikey.", + "model_id_allowlist": re.compile( + r"^(gemini-3\.1-flash-lite|gemini-3-flash-preview|" + r"gemini-3\.1-pro-preview|gemini-pro-latest|" + r"gemini-flash-latest|gemini-flash-lite-latest)$" + ), + }, + "deepseek": { + "display_name": "DeepSeek", + "base_url": "https://api.deepseek.com/v1", + "default_models": [ + "deepseek-chat", + "deepseek-reasoner", + ], + "supports_streaming": True, + "supports_vision": False, + "supports_tool_calling": True, + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "notes": "OpenAI-compatible API. deepseek-chat = V3, deepseek-reasoner = R1 thinking mode.", + }, + "mistral": { + "display_name": "Mistral AI", + "base_url": "https://api.mistral.ai/v1", + "default_models": [ + "codestral-latest", + "devstral-latest", + "devstral-medium-latest", + "magistral-medium-latest", + "ministral-14b-latest", + "ministral-3b-latest", + "ministral-8b-latest", + "mistral-large-latest", + "mistral-medium-latest", + "mistral-small-latest", + "mistral-tiny-latest", + "mistral-vibe-cli-latest", + ], + "supports_streaming": True, + "supports_vision": True, + "supports_tool_calling": True, + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "model_id_allowlist": re.compile( + r"^(codestral-latest|devstral-latest|devstral-medium-latest|" + r"magistral-medium-latest|ministral-(?:14b|3b|8b)-latest|" + r"mistral-(?:large|medium|small|tiny)-latest|" + r"mistral-vibe-cli-latest)$" + ), + }, + "kimi": { + "display_name": "Kimi", + "base_url": "https://api.moonshot.ai/v1", + # Current Kimi model lineup per the official docs: + # https://platform.kimi.ai/docs/models + # Listing/overview endpoints used to enumerate them: + # https://platform.kimi.ai/docs/api/list-models + # https://platform.kimi.ai/docs/api/overview + # kimi-k2.6 and kimi-k2.5 are the two SoTA multimodal models we + # surface in the picker; everything else (moonshot-v1-*, dated + # k2 previews) is filtered out by model_id_allowlist below. + "default_models": [ + "kimi-k2.6", + "kimi-k2.5", + ], + "supports_streaming": True, + "supports_vision": True, + "supports_tool_calling": True, + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "notes": "Moonshot API key. China: use base URL https://api.moonshot.cn/v1", + "model_id_allowlist": re.compile(r"^kimi-k2\.[56]$"), + # Both k2.6 and k2.5 are reasoning-class. The API rejects custom + # sampling: "invalid temperature: only 1 is allowed for this model" + # (and the same shape for top_p). Strip both fields from the + # outbound body so the server falls back to its required defaults. + "body_omit": ("temperature", "top_p"), + }, + "qwen": { + "display_name": "Qwen", + "base_url": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", + "default_models": [ + "qwen-plus", + "qwen-turbo", + "qwen-max", + "qwen2.5-72b-instruct", + ], + "supports_streaming": True, + "supports_vision": True, + "supports_tool_calling": True, + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "notes": "DashScope API key. China mainland: override base URL to https://dashscope.aliyuncs.com/compatible-mode/v1", + }, + "huggingface": { + "display_name": "Hugging Face", + "base_url": "https://router.huggingface.co/v1", + # Seed the picker with a few popular ids so something is selectable + # before the live /v1/models call resolves. The remote listing is + # the source of truth — see model_list_mode below. + "default_models": [ + "openai/gpt-oss-120b", + "deepseek-ai/DeepSeek-V3", + "meta-llama/Llama-3.3-70B-Instruct", + "Qwen/Qwen2.5-72B-Instruct", + ], + "supports_streaming": True, + "supports_vision": True, + "supports_tool_calling": True, + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "notes": ( + "HF token from huggingface.co/settings/tokens. Uses the " + "OpenAI-compatible router at /v1/chat/completions; /v1/models " + "returns the cross-provider chat catalog. See " + "https://huggingface.co/docs/inference-providers/index." + ), + # /v1/models works on the HF router and returns the full chat-model + # catalog (state.org/model[:policy] ids). Switch to remote so users + # see live availability — the picker has a search box, and + # loadModels() merges defaults so default_models entries remain + # visible if the remote call fails. + "model_list_mode": "remote", + # Scope the catalog to first-party org repos we trust as primary + # sources. The HF /v1/models response is otherwise hundreds of + # ids long (community fine-tunes, mirrors, fp8 variants, etc.). + "model_id_allowlist": re.compile( + r"^(openai|deepseek-ai|google|meta-llama|Qwen|moonshotai|" + r"mistralai|zai-org)/" + ), + # Cap the post-filter list. /v1/models has no server-side limit + # or popularity sort, so this is just "first N matches" — pair it + # with the default_models seed so the most useful flagship ids + # are always among the top regardless of the API's order. + "model_id_limit": 15, + }, + "openrouter": { + "display_name": "OpenRouter", + "base_url": "https://openrouter.ai/api/v1", + # Curated list for Studio's picker (explicitly locked, not live /models). + "default_models": [ + "openrouter/free", + "openai/gpt-4o", + "anthropic/claude-sonnet-4-5", + "google/gemini-2.5-flash", + "mistralai/mistral-large-2411", + "deepseek/deepseek-r1", + "mistralai/mistral-small-3.1-24b-instruct", + "perceptron/perceptron-mk1", + "inclusionai/ring-2.6-1t:free", + "google/gemini-3.1-flash-lite", + "baidu/cobuddy:free", + "openai/gpt-chat-latest", + "x-ai/grok-4.3", + "ibm-granite/granite-4.1-8b", + "openrouter/owl-alpha", + "poolside/laguna-xs.2:free", + "~google/gemini-pro-latest", + "~moonshotai/kimi-latest", + ], + "supports_streaming": True, + "supports_vision": True, + "supports_tool_calling": True, + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "extra_headers": { + "HTTP-Referer": "https://unsloth.ai", + "X-Title": "Unsloth Studio", + }, + "notes": "Unified gateway to 300+ models across all major providers. HTTP-Referer and X-Title headers sent for attribution.", + "model_list_mode": "curated", + }, +} + + +def get_provider_info(provider_type: str) -> dict[str, Any] | None: + """Return the registry entry for a provider type, or None if unknown.""" + return PROVIDER_REGISTRY.get(provider_type) + + +def get_base_url(provider_type: str) -> str | None: + """Return the default base URL for a provider type.""" + info = PROVIDER_REGISTRY.get(provider_type) + return info["base_url"] if info else None + + +def list_available_providers() -> list[dict[str, Any]]: + """Return all registered providers (for the /registry endpoint).""" + result = [] + for provider_type, info in PROVIDER_REGISTRY.items(): + result.append( + { + "provider_type": provider_type, + "display_name": info["display_name"], + "base_url": info["base_url"], + "default_models": info["default_models"], + "supports_streaming": info["supports_streaming"], + "supports_vision": info.get("supports_vision", False), + "supports_tool_calling": info.get("supports_tool_calling", False), + "model_list_mode": info.get("model_list_mode", "remote"), + } + ) + return result diff --git a/studio/backend/main.py b/studio/backend/main.py index 4955e988e6..c1c9ed1d90 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -120,6 +120,7 @@ from routes import ( inference_router, inference_studio_router, models_router, + providers_router, training_history_router, training_router, ) @@ -222,6 +223,11 @@ async def lifespan(app: FastAPI): threading.Thread(target = _precache, daemon = True).start() + # Initialize RSA key pair for API key encryption (external providers) + from core.inference.key_exchange import init_key_pair + + init_key_pair() + if storage.ensure_default_admin(): bootstrap_pw = storage.get_bootstrap_password() app.state.bootstrap_password = bootstrap_pw @@ -474,6 +480,7 @@ app.include_router(inference_studio_router, prefix = "/api/inference", tags = [" # so external tools (Open WebUI, SillyTavern, etc.) can use the # standard /v1/chat/completions path. app.include_router(inference_router, prefix = "/v1", tags = ["openai-compat"]) +app.include_router(providers_router, prefix = "/api/providers", tags = ["providers"]) app.include_router(datasets_router, prefix = "/api/datasets", tags = ["datasets"]) app.include_router(data_recipe_router, prefix = "/api/data-recipe", tags = ["data-recipe"]) app.include_router(export_router, prefix = "/api/export", tags = ["export"]) diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index 746ac8bbc2..013328f6c6 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -531,9 +531,11 @@ class ChatCompletionRequest(BaseModel): None, description = "[x-unsloth] Enable/disable thinking/reasoning mode for supported models", ) - reasoning_effort: Optional[Literal["low", "medium", "high"]] = Field( + reasoning_effort: Optional[ + Literal["none", "minimal", "low", "medium", "high", "max", "xhigh"] + ] = Field( None, - description = "[x-unsloth] Reasoning effort level ('low'|'medium'|'high') for Harmony-style reasoning models (e.g. gpt-oss). Overrides enable_thinking when the active model uses reasoning_effort style.", + description = "[x-unsloth] Reasoning effort level ('none'|'minimal'|'low'|'medium'|'high'|'max'|'xhigh'). OpenAI `/v1/responses` accepts model-dependent subsets; Anthropic adaptive thinking uses `max` as the top tier on Claude 4.6 Opus/Sonnet (inbound `xhigh` is mapped to `max`) and `xhigh` on Claude 4.7 Opus; local Harmony/gpt-oss templates support low|medium|high.", ) preserve_thinking: Optional[bool] = Field( None, @@ -570,6 +572,28 @@ class ChatCompletionRequest(BaseModel): description = "[x-unsloth] Per-request cancellation token. Frontend sends a fresh UUID per run so /inference/cancel matches one specific generation.", ) + # ── External provider routing (x-unsloth extensions) ────────── + provider_id: Optional[str] = Field( + None, + description = "[x-unsloth] Saved provider config ID. If set with encrypted_api_key, routes to external LLM.", + ) + provider_type: Optional[str] = Field( + None, + description = "[x-unsloth] Provider type (e.g. 'openai', 'mistral'). Used if provider_id is not set.", + ) + external_model: Optional[str] = Field( + None, + description = "[x-unsloth] Model ID at the external provider.", + ) + encrypted_api_key: Optional[str] = Field( + None, + description = "[x-unsloth] RSA-encrypted, base64-encoded API key for the external provider.", + ) + provider_base_url: Optional[str] = Field( + None, + description = "[x-unsloth] Override base URL for the external provider.", + ) + # ── Streaming response chunks ──────────────────────────────────── diff --git a/studio/backend/models/providers.py b/studio/backend/models/providers.py new file mode 100644 index 0000000000..5678e69f62 --- /dev/null +++ b/studio/backend/models/providers.py @@ -0,0 +1,128 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Pydantic schemas for the external LLM providers API. +""" + +from typing import Literal, Optional + +from pydantic import BaseModel, Field + + +# ── Registry (static provider info) ─────────────────────────────── + + +class ProviderRegistryEntry(BaseModel): + """A supported provider type with its default configuration.""" + + provider_type: str = Field( + ..., description = "Provider identifier (e.g. 'openai', 'mistral')" + ) + display_name: str = Field(..., description = "Human-readable provider name") + base_url: str = Field(..., description = "Default API base URL") + default_models: list[str] = Field( + default_factory = list, description = "Well-known model IDs for this provider" + ) + supports_streaming: bool = Field( + True, description = "Whether this provider supports SSE streaming" + ) + supports_vision: bool = Field( + False, description = "Whether this provider supports vision/image input" + ) + supports_tool_calling: bool = Field( + False, description = "Whether this provider supports tool/function calling" + ) + model_list_mode: Literal["remote", "curated"] = Field( + "remote", + description = "remote = fetch /models; curated = huge catalogs — UI uses defaults + manual IDs only", + ) + + +# ── Provider config CRUD ────────────────────────────────────────── + + +class ProviderCreate(BaseModel): + """Request to create a saved provider configuration.""" + + provider_type: str = Field(..., description = "Provider type from the registry") + display_name: str = Field( + ..., description = "User-chosen label (e.g. 'My OpenAI Key')" + ) + base_url: Optional[str] = Field( + None, + description = "Custom base URL (overrides registry default). Omit to use the default.", + ) + + +class ProviderUpdate(BaseModel): + """Request to update a saved provider configuration.""" + + display_name: Optional[str] = Field(None, description = "New display name") + base_url: Optional[str] = Field(None, description = "New base URL") + is_enabled: Optional[bool] = Field( + None, description = "Enable or disable this provider" + ) + + +class ProviderResponse(BaseModel): + """A saved provider configuration (returned by list/get endpoints).""" + + id: str = Field(..., description = "Unique provider config ID") + provider_type: str = Field(..., description = "Provider type (e.g. 'openai')") + display_name: str = Field(..., description = "User-chosen label") + base_url: str = Field(..., description = "API base URL") + is_enabled: bool = Field(True, description = "Whether this provider is enabled") + created_at: str = Field(..., description = "ISO 8601 creation timestamp") + updated_at: str = Field(..., description = "ISO 8601 last-update timestamp") + + +# ── Model listing ───────────────────────────────────────────────── + + +class ProviderModelInfo(BaseModel): + """A model available from an external provider.""" + + id: str = Field(..., description = "Model ID as expected by the provider API") + display_name: str = Field("", description = "Human-readable model name") + context_length: Optional[int] = Field( + None, description = "Maximum context length in tokens" + ) + owned_by: Optional[str] = Field(None, description = "Model owner/organization") + + +class ProviderModelsRequest(BaseModel): + """Request to list models from an external provider.""" + + provider_type: str = Field(..., description = "Provider type from the registry") + encrypted_api_key: str = Field( + ..., description = "RSA-encrypted, base64-encoded API key" + ) + base_url: Optional[str] = Field( + None, description = "Custom base URL (overrides registry default)" + ) + + +# ── Connection testing ──────────────────────────────────────────── + + +class ProviderTestRequest(BaseModel): + """Request to test connectivity to an external provider.""" + + provider_type: str = Field(..., description = "Provider type from the registry") + encrypted_api_key: str = Field( + ..., description = "RSA-encrypted, base64-encoded API key" + ) + base_url: Optional[str] = Field( + None, description = "Custom base URL (overrides registry default)" + ) + + +class ProviderTestResult(BaseModel): + """Result of a provider connectivity test.""" + + success: bool = Field(..., description = "Whether the test succeeded") + message: str = Field(..., description = "Human-readable result message") + models_count: Optional[int] = Field( + None, description = "Number of models found (if test succeeded)" + ) diff --git a/studio/backend/requirements/studio.txt b/studio/backend/requirements/studio.txt index 1bf751c368..96f8816b57 100644 --- a/studio/backend/requirements/studio.txt +++ b/studio/backend/requirements/studio.txt @@ -16,3 +16,5 @@ huggingface-hub==0.36.2 structlog>=24.1.0 diceware ddgs +cryptography>=42.0.0 +httpx>=0.27.0 diff --git a/studio/backend/routes/__init__.py b/studio/backend/routes/__init__.py index cf4586281b..62320b9084 100644 --- a/studio/backend/routes/__init__.py +++ b/studio/backend/routes/__init__.py @@ -14,6 +14,7 @@ from routes.auth import router as auth_router from routes.data_recipe import router as data_recipe_router from routes.export import router as export_router from routes.training_history import router as training_history_router +from routes.providers import router as providers_router __all__ = [ "training_router", @@ -25,4 +26,5 @@ __all__ = [ "data_recipe_router", "export_router", "training_history_router", + "providers_router", ] diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 7102e12bf8..59928be3cf 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -204,6 +204,11 @@ from core.inference.anthropic_compat import ( ) from auth.authentication import get_current_subject +from core.inference.key_exchange import decrypt_api_key +from core.inference.providers import get_provider_info, get_base_url +from core.inference.external_provider import ExternalProviderClient +from storage import providers_db + import io import wave import base64 @@ -1464,6 +1469,161 @@ def _extract_content_parts( return system_prompt, chat_messages, first_image_b64 +# ── External provider proxy ────────────────────────────────────── + + +def _build_external_messages( + messages: list, + supports_vision: bool, +) -> list[dict]: + """ + Convert ChatMessage list to OpenAI-compatible dicts for external providers. + + - Vision providers: preserve multimodal content arrays (image_url parts intact). + - Non-vision providers: flatten to text-only (images silently dropped). + """ + result = [] + for msg in messages: + if isinstance(msg.content, str): + # Skip assistant messages with empty content (some providers reject them) + if msg.role == "assistant" and not msg.content.strip(): + continue + result.append({"role": msg.role, "content": msg.content}) + elif isinstance(msg.content, list): + if supports_vision: + parts = [] + for part in msg.content: + if part.type == "text": + parts.append({"type": "text", "text": part.text}) + elif part.type == "image_url": + parts.append( + { + "type": "image_url", + "image_url": {"url": part.image_url.url}, + } + ) + result.append({"role": msg.role, "content": parts}) + else: + # Non-vision provider — strip images, keep text only + text = "\n".join(p.text for p in msg.content if p.type == "text") + result.append({"role": msg.role, "content": text}) + return result + + +async def _proxy_to_external_provider( + payload: ChatCompletionRequest, + request: Request, +) -> StreamingResponse: + """ + Proxy a chat completion request to an external LLM provider. + + Resolves provider config (from DB or registry), decrypts the API key, + and streams the response back in OpenAI SSE format. + """ + # Resolve provider type and base URL + provider_type = payload.provider_type + base_url = payload.provider_base_url + + if payload.provider_id: + config = providers_db.get_provider(payload.provider_id) + if config is None: + raise HTTPException( + status_code = 404, + detail = f"Provider config not found: {payload.provider_id}", + ) + if not config["is_enabled"]: + raise HTTPException( + status_code = 400, + detail = f"Provider '{config['display_name']}' is disabled.", + ) + provider_type = provider_type or config["provider_type"] + base_url = base_url or config["base_url"] + + if not provider_type: + raise HTTPException( + status_code = 400, + detail = "Either provider_id or provider_type is required for external provider routing.", + ) + + # Fall back to registry default base URL + if not base_url: + base_url = get_base_url(provider_type) + if not base_url: + raise HTTPException( + status_code = 400, + detail = f"Unknown provider type: {provider_type}", + ) + + # Decrypt the API key + try: + api_key = decrypt_api_key(payload.encrypted_api_key) + except Exception as exc: + logger.warning("external_provider.decrypt_failed", error = str(exc)) + raise HTTPException( + status_code = 400, + detail = "Failed to decrypt API key. The server key may have changed — try refreshing the page.", + ) + + model = payload.external_model or payload.model + if model == "default": + raise HTTPException( + status_code = 400, + detail = "external_model is required when using an external provider.", + ) + + # Build messages preserving multimodal content for vision-capable providers + from core.inference.providers import get_provider_info as _get_provider_info + + _pinfo = _get_provider_info(provider_type) or {} + _supports_vision = _pinfo.get("supports_vision", False) + chat_messages = _build_external_messages(payload.messages, _supports_vision) + + client = ExternalProviderClient( + provider_type = provider_type, + base_url = base_url, + api_key = api_key, + ) + + async def _stream(): + gen = client.stream_chat_completion( + messages = chat_messages, + model = model, + temperature = payload.temperature, + top_p = payload.top_p, + max_tokens = payload.max_tokens, + presence_penalty = payload.presence_penalty, + top_k = payload.top_k, + enable_thinking = payload.enable_thinking, + reasoning_effort = payload.reasoning_effort, + stream = payload.stream, + ) + try: + sent_done = False + async for line in gen: + yield f"{line}\n\n" + if "[DONE]" in line: + sent_done = True + if not sent_done: + yield "data: [DONE]\n\n" + except Exception as exc: + logger.error("external_provider.stream_error", error = str(exc)) + finally: + try: + await gen.aclose() + except RuntimeError: + pass # suppress httpcore asyncgen cleanup error (Python 3.13 + httpcore 1.0.x) + await client.close() + + return StreamingResponse( + _stream(), + media_type = "text/event-stream", + headers = { + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) + + @router.post("/chat/completions") async def openai_chat_completions( payload: ChatCompletionRequest, @@ -1483,6 +1643,10 @@ async def openai_chat_completions( - GGUF models → llama-server via LlamaCppBackend - Other models → Unsloth/transformers via InferenceBackend """ + # ── External provider routing ──────────────────────────────── + if payload.encrypted_api_key and (payload.provider_id or payload.provider_type): + return await _proxy_to_external_provider(payload, request) + llama_backend = get_llama_cpp_backend() using_gguf = llama_backend.is_loaded diff --git a/studio/backend/routes/providers.py b/studio/backend/routes/providers.py new file mode 100644 index 0000000000..e21985d60b --- /dev/null +++ b/studio/backend/routes/providers.py @@ -0,0 +1,338 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +API routes for external LLM provider management. + +Provides endpoints for: + - Discovering available provider types (registry) + - CRUD for saved provider configurations (no API keys stored) + - Fetching the RSA public key for API key encryption + - Testing provider connectivity + - Listing models from a provider +""" + +import uuid +import structlog +from fastapi import APIRouter, Depends, HTTPException + +from auth.authentication import get_current_subject +from core.inference.key_exchange import ( + decrypt_api_key, + get_public_key_fingerprint, + get_public_key_pem, +) +from core.inference.providers import ( + get_base_url, + get_provider_info, + list_available_providers, +) +from core.inference.external_provider import ExternalProviderClient +from models.providers import ( + ProviderCreate, + ProviderModelsRequest, + ProviderModelInfo, + ProviderResponse, + ProviderRegistryEntry, + ProviderTestRequest, + ProviderTestResult, + ProviderUpdate, +) +from storage import providers_db + +logger = structlog.get_logger(__name__) + +router = APIRouter() + + +# ── Public key for API key encryption ───────────────────────────── + + +@router.get("/public-key") +async def get_public_key( + current_subject: str = Depends(get_current_subject), +): + """Return the RSA public key PEM for client-side API key encryption. + + The ``fingerprint`` field is a short SHA256 of the PEM and is meant + purely for diagnostics — a mismatch between what the frontend + captured at encrypt time and what the server reports here is a + clear signal that the keypair rotated mid-flight (e.g. the server + re-ran ``init_key_pair`` for any reason). + """ + return { + "public_key": get_public_key_pem(), + "fingerprint": get_public_key_fingerprint(), + } + + +# ── Provider registry (static) ─────────────────────────────────── + + +@router.get("/registry", response_model = list[ProviderRegistryEntry]) +async def list_registry( + current_subject: str = Depends(get_current_subject), +): + """List all supported provider types with their default configurations.""" + return list_available_providers() + + +# ── Provider config CRUD ────────────────────────────────────────── + + +@router.get("/", response_model = list[ProviderResponse]) +async def list_provider_configs( + current_subject: str = Depends(get_current_subject), +): + """List all saved provider configurations.""" + rows = providers_db.list_providers() + return [ + ProviderResponse( + id = row["id"], + provider_type = row["provider_type"], + display_name = row["display_name"], + base_url = row["base_url"], + is_enabled = bool(row["is_enabled"]), + created_at = row["created_at"], + updated_at = row["updated_at"], + ) + for row in rows + ] + + +@router.post("/", response_model = ProviderResponse, status_code = 201) +async def create_provider_config( + payload: ProviderCreate, + current_subject: str = Depends(get_current_subject), +): + """Create a new saved provider configuration (no API key stored).""" + info = get_provider_info(payload.provider_type) + if info is None: + raise HTTPException( + status_code = 400, + detail = f"Unknown provider type: {payload.provider_type}. " + f"Use GET /api/providers/registry to see available types.", + ) + + provider_id = uuid.uuid4().hex[:16] + base_url = payload.base_url or info["base_url"] + + providers_db.create_provider( + id = provider_id, + provider_type = payload.provider_type, + display_name = payload.display_name, + base_url = base_url, + ) + + row = providers_db.get_provider(provider_id) + return ProviderResponse( + id = row["id"], + provider_type = row["provider_type"], + display_name = row["display_name"], + base_url = row["base_url"], + is_enabled = bool(row["is_enabled"]), + created_at = row["created_at"], + updated_at = row["updated_at"], + ) + + +@router.put("/{provider_id}", response_model = ProviderResponse) +async def update_provider_config( + provider_id: str, + payload: ProviderUpdate, + current_subject: str = Depends(get_current_subject), +): + """Update a saved provider configuration.""" + existing = providers_db.get_provider(provider_id) + if not existing: + raise HTTPException(status_code = 404, detail = "Provider not found") + + updated = providers_db.update_provider( + id = provider_id, + display_name = payload.display_name, + base_url = payload.base_url, + is_enabled = payload.is_enabled, + ) + if not updated: + raise HTTPException(status_code = 400, detail = "No fields to update") + + row = providers_db.get_provider(provider_id) + return ProviderResponse( + id = row["id"], + provider_type = row["provider_type"], + display_name = row["display_name"], + base_url = row["base_url"], + is_enabled = bool(row["is_enabled"]), + created_at = row["created_at"], + updated_at = row["updated_at"], + ) + + +@router.delete("/{provider_id}", status_code = 204) +async def delete_provider_config( + provider_id: str, + current_subject: str = Depends(get_current_subject), +): + """Delete a saved provider configuration.""" + deleted = providers_db.delete_provider(provider_id) + if not deleted: + raise HTTPException(status_code = 404, detail = "Provider not found") + + +# ── Test connectivity ───────────────────────────────────────────── + + +@router.post("/test", response_model = ProviderTestResult) +async def test_provider( + payload: ProviderTestRequest, + current_subject: str = Depends(get_current_subject), +): + """ + Test connectivity to an external provider. + + Makes a lightweight GET /models call to verify the API key works. + The encrypted_api_key is decrypted server-side and never stored. + """ + info = get_provider_info(payload.provider_type) + if info is None: + raise HTTPException( + status_code = 400, + detail = f"Unknown provider type: {payload.provider_type}", + ) + + try: + api_key = decrypt_api_key(payload.encrypted_api_key) + except Exception as exc: + logger.warning("Failed to decrypt API key (%s): %s", type(exc).__name__, exc) + raise HTTPException( + status_code = 400, + detail = "Failed to decrypt API key. The public key may have changed — try refreshing the page.", + ) + + base_url = payload.base_url or info["base_url"] + client = ExternalProviderClient( + provider_type = payload.provider_type, + base_url = base_url, + api_key = api_key, + timeout = 15.0, + ) + + try: + if info.get("model_list_mode") == "curated": + await client.verify_models_endpoint_lightweight() + return ProviderTestResult( + success = True, + message = ( + "Connected successfully. Full model list is not fetched for this provider — " + "use suggestions and manual model IDs in the dialog." + ), + models_count = None, + ) + models = await client.list_models() + return ProviderTestResult( + success = True, + message = f"Connected successfully. Found {len(models)} model(s).", + models_count = len(models), + ) + except Exception as exc: + logger.warning("Provider test failed for %s: %s", payload.provider_type, exc) + return ProviderTestResult( + success = False, + message = f"Connection failed: {exc}", + models_count = None, + ) + finally: + await client.close() + + +# ── List models from provider ───────────────────────────────────── + + +@router.post("/models", response_model = list[ProviderModelInfo]) +async def list_provider_models( + payload: ProviderModelsRequest, + current_subject: str = Depends(get_current_subject), +): + """ + List models available from an external provider. + + The encrypted_api_key is decrypted server-side and never stored. + """ + info = get_provider_info(payload.provider_type) + if info is None: + raise HTTPException( + status_code = 400, + detail = f"Unknown provider type: {payload.provider_type}", + ) + + try: + api_key = decrypt_api_key(payload.encrypted_api_key) + except Exception as exc: + logger.warning("Failed to decrypt API key (%s): %s", type(exc).__name__, exc) + raise HTTPException( + status_code = 400, + detail = "Failed to decrypt API key. The public key may have changed — try refreshing the page.", + ) + + if info.get("model_list_mode") == "curated": + return [ + ProviderModelInfo( + id = m, + display_name = m, + context_length = None, + owned_by = None, + ) + for m in info.get("default_models", []) + ] + + base_url = payload.base_url or info["base_url"] + client = ExternalProviderClient( + provider_type = payload.provider_type, + base_url = base_url, + api_key = api_key, + timeout = 15.0, + ) + + try: + models = await client.list_models() + allow_prefixes = info.get("model_id_allow_prefixes") + if allow_prefixes is not None: + prefix_tuple = tuple(str(p) for p in allow_prefixes if str(p)) + if prefix_tuple: + models = [m for m in models if m.get("id", "").startswith(prefix_tuple)] + allowlist = info.get("model_id_allowlist") + if allowlist is not None: + models = [m for m in models if allowlist.match(m.get("id", ""))] + deny_exact = info.get("model_id_deny_exact") + if deny_exact is not None: + deny_ids = {str(m) for m in deny_exact if str(m)} + if deny_ids: + models = [m for m in models if m.get("id", "") not in deny_ids] + denylist = info.get("model_id_denylist") + if denylist is not None: + models = [m for m in models if not denylist.search(m.get("id", ""))] + # Apply an optional cap after filtering so registry entries with a + # large remote catalog (e.g. HF Inference Providers) can stay + # picker-sized. No popularity sort happens server-side, so this is + # "first N matches" — pair with default_models for any must-have + # flagship ids. + limit = info.get("model_id_limit") + if isinstance(limit, int) and limit > 0: + models = models[:limit] + return [ + ProviderModelInfo( + id = m.get("id", ""), + display_name = m.get("id", ""), + context_length = m.get("context_length") or m.get("context_window"), + owned_by = m.get("owned_by"), + ) + for m in models + ] + except Exception as exc: + logger.error("Failed to list models from %s: %s", payload.provider_type, exc) + raise HTTPException( + status_code = 502, + detail = f"Failed to list models from {payload.provider_type}: {exc}", + ) + finally: + await client.close() diff --git a/studio/backend/storage/providers_db.py b/studio/backend/storage/providers_db.py new file mode 100644 index 0000000000..ca47fcbd80 --- /dev/null +++ b/studio/backend/storage/providers_db.py @@ -0,0 +1,153 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +SQLite storage for external LLM provider configurations. + +Follows the same pattern as studio_db.py — module-level functions, +raw sqlite3, WAL mode, per-function connections. + +NOTE: API keys are NOT stored here. They live only in the browser +(localStorage) and are sent encrypted per-request. +""" + +import logging +import sqlite3 +import threading +from datetime import datetime, timezone +from typing import Optional + +logger = logging.getLogger(__name__) + +from utils.paths import studio_db_path, ensure_dir + +_schema_lock = threading.Lock() +_schema_ready = False + + +def _ensure_schema(conn: sqlite3.Connection) -> None: + """Create the llm_providers table if it doesn't exist. Called once per process.""" + conn.execute("PRAGMA journal_mode=WAL") + conn.execute( + """ + CREATE TABLE IF NOT EXISTS llm_providers ( + id TEXT NOT NULL PRIMARY KEY, + provider_type TEXT NOT NULL, + display_name TEXT NOT NULL, + base_url TEXT NOT NULL, + is_enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + + +def get_connection() -> sqlite3.Connection: + """Open studio.db with WAL mode, create table once per process.""" + global _schema_ready + db_path = studio_db_path() + ensure_dir(db_path.parent) + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + if not _schema_ready: + with _schema_lock: + if not _schema_ready: + try: + _ensure_schema(conn) + _schema_ready = True + except Exception: + conn.close() + raise + return conn + + +def create_provider( + id: str, + provider_type: str, + display_name: str, + base_url: str, +) -> None: + """Insert a new provider configuration.""" + now = datetime.now(timezone.utc).isoformat() + conn = get_connection() + try: + conn.execute( + """ + INSERT INTO llm_providers (id, provider_type, display_name, base_url, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + (id, provider_type, display_name, base_url, now, now), + ) + conn.commit() + finally: + conn.close() + + +def update_provider( + id: str, + display_name: Optional[str] = None, + base_url: Optional[str] = None, + is_enabled: Optional[bool] = None, +) -> bool: + """Update fields on an existing provider. Returns True if a row was updated.""" + updates = [] + params = [] + if display_name is not None: + updates.append("display_name = ?") + params.append(display_name) + if base_url is not None: + updates.append("base_url = ?") + params.append(base_url) + if is_enabled is not None: + updates.append("is_enabled = ?") + params.append(1 if is_enabled else 0) + if not updates: + return False + updates.append("updated_at = ?") + params.append(datetime.now(timezone.utc).isoformat()) + params.append(id) + + conn = get_connection() + try: + cursor = conn.execute( + f"UPDATE llm_providers SET {', '.join(updates)} WHERE id = ?", + params, + ) + conn.commit() + return cursor.rowcount > 0 + finally: + conn.close() + + +def delete_provider(id: str) -> bool: + """Delete a provider by ID. Returns True if a row was deleted.""" + conn = get_connection() + try: + cursor = conn.execute("DELETE FROM llm_providers WHERE id = ?", (id,)) + conn.commit() + return cursor.rowcount > 0 + finally: + conn.close() + + +def get_provider(id: str) -> Optional[dict]: + """Fetch a single provider by ID.""" + conn = get_connection() + try: + row = conn.execute("SELECT * FROM llm_providers WHERE id = ?", (id,)).fetchone() + return dict(row) if row else None + finally: + conn.close() + + +def list_providers() -> list[dict]: + """List all provider configurations, ordered by creation time.""" + conn = get_connection() + try: + rows = conn.execute( + "SELECT * FROM llm_providers ORDER BY created_at" + ).fetchall() + return [dict(row) for row in rows] + finally: + conn.close() diff --git a/studio/backend/tests/test_anthropic_thinking_translation.py b/studio/backend/tests/test_anthropic_thinking_translation.py new file mode 100644 index 0000000000..14f261ae6b --- /dev/null +++ b/studio/backend/tests/test_anthropic_thinking_translation.py @@ -0,0 +1,404 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Unit tests for the Anthropic extended-thinking translation in +external_provider. + +Covers: +- Adaptive-mode request body nests effort under + ``output_config: {effort: ""}`` per the Messages API + reference (a top-level ``effort`` field 400s with + "effort: Extra inputs are not permitted"). +- Streaming SSE: ``content_block_delta`` with + ``delta.type == "thinking_delta"`` is translated into inline + ``...`` chat-completion chunks so the frontend's + reasoning-panel pipeline lifts it correctly. +- The ```` tag closes when the first ``text_delta`` arrives, + on ``content_block_stop``, on ``message_delta``, or on + ``message_stop``. +- Thinking is paired with ``temperature=1`` and no ``top_p`` / + ``top_k`` on the wire (Anthropic extended-thinking contract). +""" + +import asyncio +import json + +import httpx + +from core.inference import external_provider as ep_mod +from core.inference.external_provider import ExternalProviderClient + + +def _drive(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +async def _collect(agen): + out = [] + async for line in agen: + out.append(line) + return out + + +def _mock_http_client(monkeypatch, handler): + transport = httpx.MockTransport(handler) + monkeypatch.setattr(ep_mod, "_http_client", httpx.AsyncClient(transport = transport)) + + +def _make_client() -> ExternalProviderClient: + return ExternalProviderClient( + provider_type = "anthropic", + base_url = "https://api.anthropic.com/v1", + api_key = "sk-ant-test", + ) + + +def _anthropic_sse(events: list[dict]) -> bytes: + """Serialize a list of Messages-API event dicts as an SSE byte stream.""" + chunks: list[str] = [] + for event in events: + chunks.append(f"event: {event['type']}") + chunks.append(f"data: {json.dumps(event)}") + chunks.append("") + return ("\n".join(chunks) + "\n").encode("utf-8") + + +def _payloads_from_lines(lines: list[str]) -> list: + out = [] + for line in lines: + if not line.startswith("data:"): + continue + raw = line[len("data:") :].strip() + if not raw: + continue + if raw == "[DONE]": + out.append("[DONE]") + else: + out.append(json.loads(raw)) + return out + + +def test_adaptive_thinking_body_uses_output_config_effort_shape(monkeypatch): + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode("utf-8")) + return httpx.Response( + 200, + content = _anthropic_sse([{"type": "message_stop"}]), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + async for _ in client._stream_anthropic( + messages = [{"role": "user", "content": "hi"}], + model = "claude-opus-4-6", + temperature = 0.7, + top_p = 0.95, + max_tokens = 4096, + top_k = None, + enable_thinking = None, + reasoning_effort = "medium", + ): + pass + await client.close() + + _drive(run()) + + body = captured["body"] + # display=summarized is set explicitly so Opus 4.7 (which defaults to + # "omitted") still emits thinking_delta events for the reasoning panel. + assert body["thinking"] == {"type": "adaptive", "display": "summarized"} + # Documented shape: effort is nested under output_config. + # A top-level `effort` field produces a 400: + # "effort: Extra inputs are not permitted". + assert body["output_config"] == {"effort": "medium"} + assert "effort" not in body + # Extended-thinking contract: temperature=1, no top_p / top_k. + assert body["temperature"] == 1 + assert "top_p" not in body + assert "top_k" not in body + + +def test_adaptive_thinking_maps_xhigh_to_max_on_claude_4_6(monkeypatch): + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode("utf-8")) + return httpx.Response( + 200, + content = _anthropic_sse([{"type": "message_stop"}]), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + async for _ in client._stream_anthropic( + messages = [{"role": "user", "content": "hi"}], + model = "claude-sonnet-4-6", + temperature = 0.7, + top_p = 0.95, + max_tokens = 4096, + top_k = None, + enable_thinking = None, + reasoning_effort = "xhigh", + ): + pass + await client.close() + + _drive(run()) + + assert captured["body"]["output_config"] == {"effort": "max"} + + +def test_adaptive_thinking_keeps_max_on_claude_4_6(monkeypatch): + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode("utf-8")) + return httpx.Response( + 200, + content = _anthropic_sse([{"type": "message_stop"}]), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + async for _ in client._stream_anthropic( + messages = [{"role": "user", "content": "hi"}], + model = "claude-opus-4-6", + temperature = 0.7, + top_p = 0.95, + max_tokens = 4096, + top_k = None, + enable_thinking = None, + reasoning_effort = "max", + ): + pass + await client.close() + + _drive(run()) + + assert captured["body"]["output_config"] == {"effort": "max"} + + +def test_adaptive_thinking_keeps_xhigh_on_claude_4_7(monkeypatch): + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode("utf-8")) + return httpx.Response( + 200, + content = _anthropic_sse([{"type": "message_stop"}]), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + async for _ in client._stream_anthropic( + messages = [{"role": "user", "content": "hi"}], + model = "claude-opus-4-7", + temperature = 0.7, + top_p = 0.95, + max_tokens = 4096, + top_k = None, + enable_thinking = None, + reasoning_effort = "xhigh", + ): + pass + await client.close() + + _drive(run()) + + body = captured["body"] + assert body["output_config"] == {"effort": "xhigh"} + assert "effort" not in body + + +def test_manual_thinking_body_uses_budget_tokens_on_4_5(monkeypatch): + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode("utf-8")) + return httpx.Response( + 200, + content = _anthropic_sse([{"type": "message_stop"}]), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + async for _ in client._stream_anthropic( + messages = [{"role": "user", "content": "hi"}], + model = "claude-opus-4-5", + temperature = 0.7, + top_p = 0.95, + max_tokens = 1024, + top_k = None, + enable_thinking = None, + reasoning_effort = "high", + ): + pass + await client.close() + + _drive(run()) + + body = captured["body"] + assert body["thinking"] == {"type": "enabled", "budget_tokens": 4096} + # max_tokens must be strictly greater than budget_tokens; we shipped 1024 + # and budget is 4096, so the wrapper should bump max_tokens. + assert body["max_tokens"] > body["thinking"]["budget_tokens"] + # Manual-thinking path does not use output_config / effort — those are + # the adaptive-mode controls (Claude 4.6 / 4.7). + assert "effort" not in body + assert "output_config" not in body + + +def test_thinking_delta_wrapped_in_think_tags(monkeypatch): + def handler(request: httpx.Request) -> httpx.Response: + events = [ + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "thinking", "thinking": "", "signature": ""}, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "thinking_delta", "thinking": "First "}, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "thinking_delta", "thinking": "I plan."}, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "signature_delta", "signature": "abc123"}, + }, + {"type": "content_block_stop", "index": 0}, + { + "type": "content_block_start", + "index": 1, + "content_block": {"type": "text", "text": ""}, + }, + { + "type": "content_block_delta", + "index": 1, + "delta": {"type": "text_delta", "text": "Answer."}, + }, + {"type": "content_block_stop", "index": 1}, + {"type": "message_delta", "delta": {"stop_reason": "end_turn"}}, + {"type": "message_stop"}, + ] + return httpx.Response( + 200, + content = _anthropic_sse(events), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + lines = await _collect( + client._stream_anthropic( + messages = [{"role": "user", "content": "hi"}], + model = "claude-opus-4-6", + temperature = 0.7, + top_p = 0.95, + max_tokens = 4096, + top_k = None, + enable_thinking = True, + reasoning_effort = None, + ) + ) + await client.close() + return lines + + lines = _drive(run()) + payloads = _payloads_from_lines(lines) + + combined = "".join( + p["choices"][0]["delta"].get("content", "") + for p in payloads + if isinstance(p, dict) and p["choices"][0]["delta"] + ) + + # Reasoning text should be wrapped in ..., followed by the + # answer text, and the stream should terminate with [DONE]. + assert "First I plan." in combined + assert combined.endswith("Answer.") + # signature_delta is intentionally dropped — no leaked signature text. + assert "abc123" not in combined + assert "[DONE]" in payloads + + +def test_thinking_only_turn_closes_tag_without_text_delta(monkeypatch): + """display=omitted on Claude 4.7 emits a signature_delta and no text. + + The open is still triggered by the (synthetic) thinking_delta; + we want content_block_stop to close it cleanly so the tag never leaks + into the next chunk.""" + + def handler(request: httpx.Request) -> httpx.Response: + events = [ + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "thinking", "thinking": "", "signature": ""}, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "thinking_delta", "thinking": "internal"}, + }, + {"type": "content_block_stop", "index": 0}, + {"type": "message_delta", "delta": {"stop_reason": "end_turn"}}, + {"type": "message_stop"}, + ] + return httpx.Response( + 200, + content = _anthropic_sse(events), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + lines = await _collect( + client._stream_anthropic( + messages = [{"role": "user", "content": "hi"}], + model = "claude-opus-4-7", + temperature = 0.7, + top_p = 0.95, + max_tokens = 4096, + top_k = None, + enable_thinking = True, + reasoning_effort = None, + ) + ) + await client.close() + return lines + + payloads = _payloads_from_lines(_drive(run())) + combined = "".join( + p["choices"][0]["delta"].get("content", "") + for p in payloads + if isinstance(p, dict) and p["choices"][0]["delta"] + ) + assert combined == "internal" diff --git a/studio/backend/tests/test_desktop_auth.py b/studio/backend/tests/test_desktop_auth.py index a7201ac433..913c3cc355 100644 --- a/studio/backend/tests/test_desktop_auth.py +++ b/studio/backend/tests/test_desktop_auth.py @@ -437,6 +437,7 @@ def test_health_response_reports_desktop_capability_fields(monkeypatch): inference_router = APIRouter(), inference_studio_router = APIRouter(), models_router = APIRouter(), + providers_router = APIRouter(), training_history_router = APIRouter(), training_router = APIRouter(), ) diff --git a/studio/backend/tests/test_openai_responses_translation.py b/studio/backend/tests/test_openai_responses_translation.py new file mode 100644 index 0000000000..4ad6a19ea9 --- /dev/null +++ b/studio/backend/tests/test_openai_responses_translation.py @@ -0,0 +1,432 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Unit tests for the OpenAI `/v1/responses` translation in external_provider. + +Covers: +- Request body shape: system messages collapse into `instructions`, user/ + assistant messages go into `input`, sampling knobs Responses does not + support (presence_penalty, top_k) are not forwarded. +- SSE translation: `response.output_text.delta` events become OpenAI Chat + Completions chunks, `response.completed` emits a `finish_reason: stop` + chunk, the stream terminates with `data: [DONE]`. +- Image parts in user content are rewritten from Chat Completions + `{type: image_url, image_url: {url}}` into Responses + `{type: input_image, image_url: }`. +""" + +import asyncio +import json + +import httpx + +from core.inference import external_provider as ep_mod +from core.inference.external_provider import ExternalProviderClient + + +def _drive(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +async def _collect(agen): + out = [] + async for line in agen: + out.append(line) + return out + + +def _mock_http_client(monkeypatch, handler): + transport = httpx.MockTransport(handler) + monkeypatch.setattr(ep_mod, "_http_client", httpx.AsyncClient(transport = transport)) + + +def _make_client() -> ExternalProviderClient: + return ExternalProviderClient( + provider_type = "openai", + base_url = "https://api.openai.com/v1", + api_key = "sk-test", + ) + + +def _responses_sse(events: list[dict]) -> bytes: + """Serialize a list of Responses-API event dicts as an SSE byte stream.""" + chunks: list[str] = [] + for event in events: + chunks.append(f"event: {event['type']}") + chunks.append(f"data: {json.dumps(event)}") + chunks.append("") + chunks.append("data: [DONE]") + chunks.append("") + return ("\n".join(chunks) + "\n").encode("utf-8") + + +def test_responses_request_body_uses_input_and_instructions(monkeypatch): + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + captured["body"] = json.loads(request.content.decode("utf-8")) + return httpx.Response( + 200, + content = _responses_sse([{"type": "response.completed", "response": {}}]), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + async for _ in client._stream_openai_responses( + messages = [ + {"role": "system", "content": "You are concise."}, + {"role": "user", "content": "Hi"}, + ], + model = "gpt-5.5", + temperature = 0.5, + top_p = 0.9, + max_tokens = 512, + enable_thinking = None, + reasoning_effort = None, + ): + pass + await client.close() + + _drive(run()) + + assert captured["url"] == "https://api.openai.com/v1/responses" + body = captured["body"] + assert body["model"] == "gpt-5.5" + assert body["instructions"] == "You are concise." + assert body["input"] == [{"role": "user", "content": "Hi"}] + assert body["max_output_tokens"] == 512 + assert body["stream"] is True + # Responses API on reasoning-class models (gpt-5.x / o3 / gpt-4.5 — the + # only OpenAI ids the registry allowlist exposes) rejects these as + # `Unsupported parameter`. Make sure we never silently forward them. + assert "temperature" not in body + assert "top_p" not in body + assert "presence_penalty" not in body + assert "frequency_penalty" not in body + assert "top_k" not in body + assert "messages" not in body + + +def test_responses_translates_image_parts(monkeypatch): + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode("utf-8")) + return httpx.Response( + 200, + content = _responses_sse([{"type": "response.completed", "response": {}}]), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + async for _ in client._stream_openai_responses( + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What is this?"}, + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,AAA"}, + }, + ], + } + ], + model = "gpt-5.5", + temperature = 0.7, + top_p = 0.95, + max_tokens = None, + enable_thinking = None, + reasoning_effort = None, + ): + pass + await client.close() + + _drive(run()) + + parts = captured["body"]["input"][0]["content"] + assert parts[0] == {"type": "input_text", "text": "What is this?"} + assert parts[1] == { + "type": "input_image", + "image_url": "data:image/png;base64,AAA", + } + # No max_output_tokens key when caller passes max_tokens=None. + assert "max_output_tokens" not in captured["body"] + + +def test_responses_sse_translates_to_chat_completions_chunks(monkeypatch): + def handler(request: httpx.Request) -> httpx.Response: + events = [ + {"type": "response.created"}, + {"type": "response.output_text.delta", "delta": "Hello"}, + {"type": "response.output_text.delta", "delta": ", world"}, + {"type": "response.completed", "response": {}}, + ] + return httpx.Response( + 200, + content = _responses_sse(events), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + lines = await _collect( + client._stream_openai_responses( + messages = [{"role": "user", "content": "hi"}], + model = "gpt-5.5", + temperature = 0.7, + top_p = 0.95, + max_tokens = None, + enable_thinking = None, + reasoning_effort = None, + ) + ) + await client.close() + return lines + + lines = _drive(run()) + + # Drop empty / non-data lines for assertion clarity. + data_lines = [line for line in lines if line.startswith("data:")] + payloads = [] + for line in data_lines: + raw = line[len("data:") :].strip() + if raw == "[DONE]": + payloads.append("[DONE]") + else: + payloads.append(json.loads(raw)) + + # Two text deltas, one terminal chunk, then [DONE]. + assert payloads[0]["choices"][0]["delta"]["content"] == "Hello" + assert payloads[0]["choices"][0]["finish_reason"] is None + assert payloads[1]["choices"][0]["delta"]["content"] == ", world" + assert payloads[2]["choices"][0]["delta"] == {} + assert payloads[2]["choices"][0]["finish_reason"] == "stop" + assert payloads[-1] == "[DONE]" + + +def test_responses_response_incomplete_maps_to_length_finish_reason(monkeypatch): + def handler(request: httpx.Request) -> httpx.Response: + events = [ + {"type": "response.output_text.delta", "delta": "partial"}, + {"type": "response.incomplete", "response": {}}, + ] + return httpx.Response( + 200, + content = _responses_sse(events), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + lines = await _collect( + client._stream_openai_responses( + messages = [{"role": "user", "content": "hi"}], + model = "gpt-5.5", + temperature = 0.7, + top_p = 0.95, + max_tokens = 4, + enable_thinking = None, + reasoning_effort = None, + ) + ) + await client.close() + return lines + + lines = _drive(run()) + finish_reasons = [ + json.loads(line[len("data:") :].strip())["choices"][0]["finish_reason"] + for line in lines + if line.startswith("data:") + and line[len("data:") :].strip() not in ("", "[DONE]") + ] + assert "length" in finish_reasons + + +def test_responses_reasoning_effort_included_when_requested(monkeypatch): + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode("utf-8")) + return httpx.Response( + 200, + content = _responses_sse([{"type": "response.completed", "response": {}}]), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + async for _ in client._stream_openai_responses( + messages = [{"role": "user", "content": "hi"}], + model = "gpt-5.5", + temperature = 0.7, + top_p = 0.95, + max_tokens = None, + enable_thinking = None, + reasoning_effort = "high", + ): + pass + await client.close() + + _drive(run()) + assert captured["body"]["reasoning"] == {"effort": "high", "summary": "auto"} + + +def test_responses_reasoning_effort_none_omits_summary(monkeypatch): + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode("utf-8")) + return httpx.Response( + 200, + content = _responses_sse([{"type": "response.completed", "response": {}}]), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + async for _ in client._stream_openai_responses( + messages = [{"role": "user", "content": "hi"}], + model = "gpt-5.5", + temperature = 0.7, + top_p = 0.95, + max_tokens = None, + enable_thinking = None, + reasoning_effort = "none", + ): + pass + await client.close() + + _drive(run()) + assert captured["body"]["reasoning"] == {"effort": "none"} + + +def test_responses_reasoning_effort_xhigh_passthrough(monkeypatch): + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode("utf-8")) + return httpx.Response( + 200, + content = _responses_sse([{"type": "response.completed", "response": {}}]), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + async for _ in client._stream_openai_responses( + messages = [{"role": "user", "content": "hi"}], + model = "gpt-5.5", + temperature = 0.7, + top_p = 0.95, + max_tokens = None, + enable_thinking = None, + reasoning_effort = "xhigh", + ): + pass + await client.close() + + _drive(run()) + assert captured["body"]["reasoning"] == {"effort": "xhigh", "summary": "auto"} + + +def test_responses_enable_thinking_false_maps_to_reasoning_none(monkeypatch): + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode("utf-8")) + return httpx.Response( + 200, + content = _responses_sse([{"type": "response.completed", "response": {}}]), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + async for _ in client._stream_openai_responses( + messages = [{"role": "user", "content": "hi"}], + model = "gpt-5.5", + temperature = 0.7, + top_p = 0.95, + max_tokens = None, + enable_thinking = False, + reasoning_effort = None, + ): + pass + await client.close() + + _drive(run()) + assert captured["body"]["reasoning"] == {"effort": "none"} + + +def test_responses_reasoning_summary_wrapped_in_think_tags(monkeypatch): + def handler(request: httpx.Request) -> httpx.Response: + events = [ + { + "type": "response.output_item.done", + "item": { + "type": "reasoning", + "summary": [{"type": "summary_text", "text": "plan"}], + }, + }, + {"type": "response.output_text.delta", "delta": "answer"}, + {"type": "response.completed", "response": {}}, + ] + return httpx.Response( + 200, + content = _responses_sse(events), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + lines = await _collect( + client._stream_openai_responses( + messages = [{"role": "user", "content": "hi"}], + model = "gpt-5.5", + temperature = 0.7, + top_p = 0.95, + max_tokens = None, + enable_thinking = None, + reasoning_effort = None, + ) + ) + await client.close() + return lines + + lines = _drive(run()) + data_lines = [ + line[len("data:") :].strip() + for line in lines + if line.startswith("data:") + and line[len("data:") :].strip() not in ("", "[DONE]") + ] + payloads = [json.loads(raw) for raw in data_lines] + combined = "".join( + payload["choices"][0]["delta"].get("content", "") + for payload in payloads + if payload["choices"][0]["delta"] + ) + assert "plananswer" in combined diff --git a/studio/backend/tests/test_providers_api.py b/studio/backend/tests/test_providers_api.py new file mode 100644 index 0000000000..0e668944f4 --- /dev/null +++ b/studio/backend/tests/test_providers_api.py @@ -0,0 +1,609 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Integration tests for the external providers API. + +Requires a running Unsloth Studio server. Configure via environment variables: + + export STUDIO_TEST_URL="http://localhost:8888" # default + export STUDIO_TEST_USER="unsloth" # default + export STUDIO_TEST_PASSWORD="..." # required — see .bootstrap_password + + # Provider API keys — any left unset will have their tests automatically skipped + export OPENAI_API_KEY="sk-..." + export MISTRAL_API_KEY="..." + export GOOGLE_API_KEY="..." + export TOGETHER_API_KEY="..." + export FIREWORKS_API_KEY="..." + export PERPLEXITY_API_KEY="..." + +Run: + cd studio/backend + pytest tests/test_providers_api.py -v -s +""" + +import base64 +import json +import os + +import pytest +import requests +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding + +# ── Configuration ───────────────────────────────────────────────── + +BASE_URL = os.getenv("STUDIO_TEST_URL", "http://localhost:8000") +USERNAME = os.getenv("STUDIO_TEST_USER", "unsloth") +PASSWORD = os.getenv("STUDIO_TEST_PASSWORD", "") + +# These tests require a live Studio server reachable at BASE_URL with a known +# bootstrap password. Skip the whole module when that environment is missing +# (e.g. on CI runners) so pytest discovery does not error out. +pytestmark = pytest.mark.skipif( + not PASSWORD, + reason = "Integration test requires a running Studio server; set STUDIO_TEST_PASSWORD to enable.", +) + +# Map provider_type → (env var name, model to use for inference test) +_PROVIDER_CONFIGS: dict[str, tuple[str, str]] = { + "openai": ("OPENAI_API_KEY", "gpt-4o-mini"), + "mistral": ("MISTRAL_API_KEY", "mistral-small-2506"), + "gemini": ("GEMINI_API_KEY", "gemini-3-flash-preview"), + "openrouter": ("OPENROUTER_API_KEY", "openai/gpt-4o-mini"), + "anthropic": ("ANTHROPIC_API_KEY", "claude-haiku-4-5"), + "deepseek": ("DEEPSEEK_API_KEY", "deepseek-chat"), + "huggingface": ("HUGGINGFACE_API_KEY", "meta-llama/Llama-3.3-70B-Instruct"), + "kimi": ("MOONSHOT_API_KEY", "moonshot-v1-8k"), + "qwen": ("DASHSCOPE_API_KEY", "qwen-turbo"), +} + +PROVIDER_KEYS: dict[str, str] = { + ptype: os.getenv(env_var, "") for ptype, (env_var, _) in _PROVIDER_CONFIGS.items() +} + +EXPECTED_PROVIDER_TYPES = set(_PROVIDER_CONFIGS.keys()) + +# ── Helpers ──────────────────────────────────────────────────────── + + +def _url(path: str) -> str: + return f"{BASE_URL}/{path.lstrip('/')}" + + +def _parse_sse_stream(response: requests.Response) -> tuple[str, bool]: + """ + Read a streaming SSE response and return (assembled_text, saw_done). + + Each chunk is a JSON object with choices[0].delta.content. + The stream ends with `data: [DONE]`. + """ + reply_parts: list[str] = [] + saw_done = False + + for raw_line in response.iter_lines(): + if isinstance(raw_line, bytes): + raw_line = raw_line.decode("utf-8") + if not raw_line.startswith("data:"): + continue + data = raw_line[len("data:") :].strip() + if data == "[DONE]": + saw_done = True + break + try: + chunk = json.loads(data) + # Handle both error payloads and normal chunks + if "error" in chunk: + raise RuntimeError(f"Provider error in stream: {chunk['error']}") + delta = chunk.get("choices", [{}])[0].get("delta", {}) + content = delta.get("content") or "" + if content: + reply_parts.append(content) + except (json.JSONDecodeError, IndexError, KeyError): + pass # skip malformed lines + + return "".join(reply_parts), saw_done + + +# ── Session-scoped fixtures ──────────────────────────────────────── + + +@pytest.fixture(scope = "session") +def auth_headers() -> dict[str, str]: + """ + Log in once per session and return auth headers. + + On a fresh Studio install the bootstrap password triggers a forced password + change (must_change_password=True). Any subsequent API call using that token + returns 403 "Password change required". This fixture detects that state, + automatically completes the change-password flow, and re-logs in so all other + tests get a fully usable token. + + The new password used during auto-change is: + STUDIO_TEST_NEW_PASSWORD (env var, optional) + or PASSWORD + "-test" (derived default) + + On the second run, set STUDIO_TEST_PASSWORD to the new password. + """ + assert PASSWORD, ( + "STUDIO_TEST_PASSWORD is not set.\n" + "Run: export STUDIO_TEST_PASSWORD=$(cat studio/backend/.bootstrap_password)" + ) + + resp = requests.post( + _url("/api/auth/login"), + json = {"username": USERNAME, "password": PASSWORD}, + timeout = 10, + ) + assert resp.status_code == 200, f"Login failed ({resp.status_code}): {resp.text}" + body = resp.json() + token = body["access_token"] + assert token, "access_token is empty" + + if body.get("must_change_password"): + # Bootstrap token is restricted — only /api/auth/change-password works with it. + # Auto-complete the forced change so the rest of the tests get a full token. + new_password = os.getenv("STUDIO_TEST_NEW_PASSWORD") or f"{PASSWORD}-test" + change_resp = requests.post( + _url("/api/auth/change-password"), + headers = {"Authorization": f"Bearer {token}"}, + json = {"current_password": PASSWORD, "new_password": new_password}, + timeout = 10, + ) + assert ( + change_resp.status_code == 200 + ), f"Auto password-change failed ({change_resp.status_code}): {change_resp.text}" + token = change_resp.json()["access_token"] + + return {"Authorization": f"Bearer {token}"} + + +@pytest.fixture(scope = "session") +def public_key_pem(auth_headers: dict[str, str]) -> str: + """Fetch RSA public key PEM once per session.""" + resp = requests.get( + _url("/api/providers/public-key"), + headers = auth_headers, + timeout = 10, + ) + assert resp.status_code == 200, f"Public key fetch failed: {resp.text}" + pem = resp.json().get("public_key", "") + assert pem.startswith("-----BEGIN PUBLIC KEY-----"), "Not a valid PEM public key" + return pem + + +@pytest.fixture(scope = "session") +def vision_image_data_url() -> str: + """ + Download the sloth image once per session and return it as a base64 data URI. + + Using a data URI instead of a remote URL ensures every provider receives + the image inline — Gemini's OpenAI-compatible layer does not fetch external + HTTP URLs, so raw image_url links silently produce empty replies for Gemini. + """ + resp = requests.get(_VISION_IMAGE_URL, timeout = 30) + resp.raise_for_status() + content_type = resp.headers.get("Content-Type", "image/jpeg").split(";")[0].strip() + b64 = base64.b64encode(resp.content).decode("utf-8") + return f"data:{content_type};base64,{b64}" + + +@pytest.fixture(scope = "session") +def encrypt_key(public_key_pem: str): + """ + Return a callable encrypt_key(plaintext: str) -> str (base64 RSA-OAEP ciphertext). + Uses the backend's RSA public key — mirrors what the frontend does. + """ + # Decode PEM → load RSA public key + pem_bytes = public_key_pem.encode("utf-8") + rsa_pub = serialization.load_pem_public_key(pem_bytes) + + def _encrypt(plaintext: str) -> str: + ciphertext = rsa_pub.encrypt( + plaintext.encode("utf-8"), + padding.OAEP( + mgf = padding.MGF1(algorithm = hashes.SHA256()), + algorithm = hashes.SHA256(), + label = None, + ), + ) + return base64.b64encode(ciphertext).decode("utf-8") + + return _encrypt + + +# ── TestAuth ──────────────────────────────────────────────────────── + + +class TestAuth: + def test_login_returns_token(self): + """POST /api/auth/login returns a non-empty access_token.""" + assert PASSWORD, "STUDIO_TEST_PASSWORD not set" + resp = requests.post( + _url("/api/auth/login"), + json = {"username": USERNAME, "password": PASSWORD}, + timeout = 10, + ) + assert ( + resp.status_code == 200 + ), f"Login failed ({resp.status_code}): {resp.text}" + body = resp.json() + assert body.get("access_token"), "access_token is missing or empty" + assert body.get("token_type") == "bearer" + + +# ── TestPublicKey ──────────────────────────────────────────────────── + + +class TestPublicKey: + def test_public_key_is_valid_pem( + self, auth_headers: dict[str, str], public_key_pem: str + ): + """GET /api/providers/public-key returns an importable RSA PEM key.""" + pem_bytes = public_key_pem.encode("utf-8") + key = serialization.load_pem_public_key(pem_bytes) + key_size = key.key_size # type: ignore[attr-defined] + assert key_size >= 2048, f"Key size too small: {key_size}" + print(f"\n RSA-{key_size} public key OK") + + +# ── TestRegistry ──────────────────────────────────────────────────── + + +class TestRegistry: + def test_registry_returns_all_providers(self, auth_headers: dict[str, str]): + """GET /api/providers/registry returns all supported providers.""" + resp = requests.get( + _url("/api/providers/registry"), + headers = auth_headers, + timeout = 10, + ) + assert resp.status_code == 200, f"Registry failed: {resp.text}" + providers = resp.json() + assert ( + len(providers) == 9 + ), f"Expected 9 providers, got {len(providers)}: {providers}" + print(f"\n {'Provider':<12} {'Base URL'}") + print(f" {'-'*12} {'-'*45}") + for p in providers: + print(f" {p['provider_type']:<12} {p['base_url']}") + + def test_registry_has_expected_types(self, auth_headers: dict[str, str]): + """All expected provider_type values are present in the registry.""" + resp = requests.get( + _url("/api/providers/registry"), + headers = auth_headers, + timeout = 10, + ) + assert resp.status_code == 200 + returned_types = {p["provider_type"] for p in resp.json()} + missing = EXPECTED_PROVIDER_TYPES - returned_types + assert not missing, f"Missing provider types: {missing}" + + def test_registry_entries_have_required_fields(self, auth_headers: dict[str, str]): + """Each registry entry has provider_type, display_name, base_url, default_models.""" + resp = requests.get( + _url("/api/providers/registry"), headers = auth_headers, timeout = 10 + ) + assert resp.status_code == 200 + for entry in resp.json(): + for field in ( + "provider_type", + "display_name", + "base_url", + "default_models", + "model_list_mode", + ): + assert field in entry, f"Missing field '{field}' in entry: {entry}" + assert entry["model_list_mode"] in ("remote", "curated") + assert isinstance(entry["default_models"], list) + assert len(entry["default_models"]) > 0 + + +# ── TestProviderCRUD ──────────────────────────────────────────────── + + +class TestProviderCRUD: + """ + These tests run sequentially within the class and share state via class variables. + They create, read, update, and delete a single test provider config. + """ + + _created_id: str = "" + + def test_create_provider(self, auth_headers: dict[str, str]): + """POST /api/providers/ creates a provider config and returns 201.""" + resp = requests.post( + _url("/api/providers/"), + headers = auth_headers, + json = {"provider_type": "openai", "display_name": "Test OpenAI (pytest)"}, + timeout = 10, + ) + assert ( + resp.status_code == 201 + ), f"Create failed ({resp.status_code}): {resp.text}" + body = resp.json() + assert body.get("id"), "No id in response" + assert body["provider_type"] == "openai" + assert body["display_name"] == "Test OpenAI (pytest)" + assert body["is_enabled"] is True + TestProviderCRUD._created_id = body["id"] + print(f"\n created id={body['id']}") + + def test_list_includes_created(self, auth_headers: dict[str, str]): + """GET /api/providers/ includes the newly created config.""" + assert ( + TestProviderCRUD._created_id + ), "No created_id (run test_create_provider first)" + resp = requests.get(_url("/api/providers/"), headers = auth_headers, timeout = 10) + assert resp.status_code == 200 + ids = [p["id"] for p in resp.json()] + assert ( + TestProviderCRUD._created_id in ids + ), f"Created id {TestProviderCRUD._created_id!r} not found in list: {ids}" + print(f"\n found id={TestProviderCRUD._created_id} in list of {len(ids)}") + + def test_update_display_name(self, auth_headers: dict[str, str]): + """PUT /api/providers/{id} updates the display_name.""" + assert TestProviderCRUD._created_id, "No created_id" + new_name = "Test OpenAI (pytest updated)" + resp = requests.put( + _url(f"/api/providers/{TestProviderCRUD._created_id}"), + headers = auth_headers, + json = {"display_name": new_name}, + timeout = 10, + ) + assert ( + resp.status_code == 200 + ), f"Update failed ({resp.status_code}): {resp.text}" + assert resp.json()["display_name"] == new_name + print(f"\n updated display_name to '{new_name}'") + + def test_delete_provider(self, auth_headers: dict[str, str]): + """DELETE /api/providers/{id} removes the config (204) and it's gone from list.""" + assert TestProviderCRUD._created_id, "No created_id" + resp = requests.delete( + _url(f"/api/providers/{TestProviderCRUD._created_id}"), + headers = auth_headers, + timeout = 10, + ) + assert ( + resp.status_code == 204 + ), f"Delete failed ({resp.status_code}): {resp.text}" + + # Confirm gone from list + list_resp = requests.get( + _url("/api/providers/"), headers = auth_headers, timeout = 10 + ) + ids = [p["id"] for p in list_resp.json()] + assert TestProviderCRUD._created_id not in ids, "Deleted provider still in list" + print(f"\n deleted id={TestProviderCRUD._created_id} confirmed gone") + + +# ── TestProviderInference ──────────────────────────────────────────── + + +# Build parametrize list: (provider_type, model, api_key) for configured providers only +_INFERENCE_PARAMS = [ + pytest.param( + ptype, + model, + PROVIDER_KEYS.get(ptype, ""), + id = ptype, + marks = pytest.mark.skipif( + not PROVIDER_KEYS.get(ptype, ""), + reason = f"no {env_var} set", + ), + ) + for ptype, (env_var, model) in _PROVIDER_CONFIGS.items() +] + + +class TestProviderInference: + """ + Live inference tests — one parametrized set per provider. + Each test is automatically skipped when the provider's API key env var is not set. + """ + + @pytest.mark.parametrize("provider_type,model,api_key", _INFERENCE_PARAMS) + def test_connection( + self, + auth_headers: dict[str, str], + encrypt_key, + provider_type: str, + model: str, + api_key: str, + ): + """POST /api/providers/test → success: true.""" + encrypted = encrypt_key(api_key) + resp = requests.post( + _url("/api/providers/test"), + headers = auth_headers, + json = {"provider_type": provider_type, "encrypted_api_key": encrypted}, + timeout = 30, + ) + assert ( + resp.status_code == 200 + ), f"Request failed ({resp.status_code}): {resp.text}" + body = resp.json() + assert ( + body["success"] is True + ), f"Connection test failed for {provider_type}: {body.get('message')}" + print(f"\n [{provider_type}] connection OK — {body['message']}") + + @pytest.mark.parametrize("provider_type,model,api_key", _INFERENCE_PARAMS) + def test_list_models( + self, + auth_headers: dict[str, str], + encrypt_key, + provider_type: str, + model: str, + api_key: str, + ): + """POST /api/providers/models → non-empty list, print first 3.""" + encrypted = encrypt_key(api_key) + resp = requests.post( + _url("/api/providers/models"), + headers = auth_headers, + json = {"provider_type": provider_type, "encrypted_api_key": encrypted}, + timeout = 30, + ) + assert ( + resp.status_code == 200 + ), f"Request failed ({resp.status_code}): {resp.text}" + models = resp.json() + assert isinstance(models, list), f"Expected list, got {type(models)}" + assert len(models) > 0, f"No models returned for {provider_type}" + preview = [m["id"] for m in models[:3]] + print(f"\n [{provider_type}] {len(models)} models — first 3: {preview}") + + @pytest.mark.parametrize("provider_type,model,api_key", _INFERENCE_PARAMS) + def test_chat_inference( + self, + auth_headers: dict[str, str], + encrypt_key, + provider_type: str, + model: str, + api_key: str, + ): + """POST /v1/chat/completions with provider fields → streamed reply.""" + encrypted = encrypt_key(api_key) + payload = { + "messages": [{"role": "user", "content": "Say hello in one sentence."}], + "stream": True, + "temperature": 0.7, + "max_tokens": 64, + "provider_type": provider_type, + "external_model": model, + "encrypted_api_key": encrypted, + } + with requests.post( + _url("/v1/chat/completions"), + headers = {**auth_headers, "Content-Type": "application/json"}, + json = payload, + stream = True, + timeout = 60, + ) as resp: + assert ( + resp.status_code == 200 + ), f"Chat completions failed ({resp.status_code}): {resp.text[:500]}" + reply, saw_done = _parse_sse_stream(resp) + + assert reply.strip(), f"Empty reply from {provider_type}/{model}" + assert saw_done, f"Stream did not end with [DONE] for {provider_type}/{model}" + print(f'\n [{provider_type}/{model}] reply: "{reply.strip()}"') + + +# ── TestVisionInference ───────────────────────────────────────────── + +# Sloth photo — used to test vision routing across providers +_VISION_IMAGE_URL = ( + "https://www.travelexcellence.com/images/where-to-see-sloths-in-costa-rica.jpg" +) + +_VISION_PARAMS = [ + pytest.param( + ptype, + model, + PROVIDER_KEYS.get(ptype, ""), + id = ptype, + marks = pytest.mark.skipif( + not PROVIDER_KEYS.get(ptype, ""), + reason = f"no key for {ptype}", + ), + ) + for ptype, (_, model) in _PROVIDER_CONFIGS.items() + if ptype in {"openai", "mistral", "gemini", "anthropic", "openrouter"} +] + + +class TestVisionInference: + """ + Send a 1×1 white PNG alongside a text question to each vision-capable provider. + Verifies that image content parts survive the proxy and the provider replies. + """ + + @pytest.mark.parametrize("provider_type,model,api_key", _VISION_PARAMS) + def test_vision_chat_inference( + self, + auth_headers: dict[str, str], + encrypt_key, + vision_image_data_url: str, + provider_type: str, + model: str, + api_key: str, + ): + """Image URL + text message → non-empty streamed reply.""" + encrypted = encrypt_key(api_key) + payload = { + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Which animal is in this image? Reply in one word.", + }, + { + "type": "image_url", + "image_url": {"url": vision_image_data_url}, + }, + ], + } + ], + "stream": True, + "max_tokens": 215, + "provider_type": provider_type, + "external_model": model, + "encrypted_api_key": encrypted, + } + with requests.post( + _url("/v1/chat/completions"), + headers = {**auth_headers, "Content-Type": "application/json"}, + json = payload, + stream = True, + timeout = 60, + ) as resp: + assert ( + resp.status_code == 200 + ), f"Vision request failed ({resp.status_code}): {resp.text[:300]}" + reply, saw_done = _parse_sse_stream(resp) + + assert reply.strip(), f"Empty reply from {provider_type}/{model}" + assert saw_done, f"Stream did not end with [DONE] for {provider_type}/{model}" + print(f"\n [{provider_type}/{model}] vision reply: {reply.strip()!r}") + + +# ── TestLocalInferenceUnaffected ──────────────────────────────────── + + +class TestLocalInferenceUnaffected: + def test_chat_without_provider(self, auth_headers: dict[str, str]): + """ + POST /v1/chat/completions without provider fields must not return 422 or 500. + + 200 = a local model is loaded and responded. + 503 = no model loaded (expected in test environment — that's fine). + Any other 4xx/5xx (except 503) = regression in request handling. + """ + resp = requests.post( + _url("/v1/chat/completions"), + headers = {**auth_headers, "Content-Type": "application/json"}, + json = { + "messages": [{"role": "user", "content": "Hello"}], + "stream": False, + }, + timeout = 15, + ) + allowed = {200, 400, 503} + assert resp.status_code in allowed, ( + f"Unexpected status {resp.status_code} for local inference path: {resp.text[:300]}\n" + f"This likely means the provider fields broke the base request schema." + ) + status_label = ( + "local model responded" + if resp.status_code == 200 + else "no model loaded (expected)" + ) + print(f"\n status={resp.status_code} ({status_label}) — local path unaffected") diff --git a/studio/frontend/package-lock.json b/studio/frontend/package-lock.json index 21d31d81e6..464f47c09c 100644 --- a/studio/frontend/package-lock.json +++ b/studio/frontend/package-lock.json @@ -58,6 +58,7 @@ "motion": "^12.34.0", "next": "^16.1.6", "next-themes": "^0.4.6", + "node-forge": "^1.4.0", "radix-ui": "^1.4.3", "react": "^19.2.4", "react-day-picker": "^9.13.2", @@ -80,6 +81,7 @@ "@eslint/js": "^9.39.1", "@types/js-yaml": "^4.0.9", "@types/node": "^25.5.2", + "@types/node-forge": "^1.3.14", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", @@ -7377,6 +7379,16 @@ "undici-types": "~7.19.0" } }, + "node_modules/@types/node-forge": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz", + "integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -13285,6 +13297,15 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/node-releases": { "version": "2.0.38", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", diff --git a/studio/frontend/package.json b/studio/frontend/package.json index 3a02bb926a..c69b2fdf3e 100644 --- a/studio/frontend/package.json +++ b/studio/frontend/package.json @@ -66,6 +66,7 @@ "motion": "^12.34.0", "next": "^16.1.6", "next-themes": "^0.4.6", + "node-forge": "^1.4.0", "radix-ui": "^1.4.3", "react": "^19.2.4", "react-day-picker": "^9.13.2", @@ -92,6 +93,7 @@ "@biomejs/biome": "^1.9.4", "@eslint/js": "^9.39.1", "@types/js-yaml": "^4.0.9", + "@types/node-forge": "^1.3.14", "@types/node": "^25.5.2", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", diff --git a/studio/frontend/public/provider-logos/anthropic.svg b/studio/frontend/public/provider-logos/anthropic.svg new file mode 100644 index 0000000000..7545cc8f3e --- /dev/null +++ b/studio/frontend/public/provider-logos/anthropic.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/studio/frontend/public/provider-logos/deepseek.svg b/studio/frontend/public/provider-logos/deepseek.svg new file mode 100644 index 0000000000..d1ba06b942 --- /dev/null +++ b/studio/frontend/public/provider-logos/deepseek.svg @@ -0,0 +1,14 @@ + + + + + + + + + \ No newline at end of file diff --git a/studio/frontend/public/provider-logos/gemini.svg b/studio/frontend/public/provider-logos/gemini.svg new file mode 100644 index 0000000000..9090dfb68e --- /dev/null +++ b/studio/frontend/public/provider-logos/gemini.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/studio/frontend/public/provider-logos/huggingface.svg b/studio/frontend/public/provider-logos/huggingface.svg new file mode 100644 index 0000000000..ab959d165f --- /dev/null +++ b/studio/frontend/public/provider-logos/huggingface.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/studio/frontend/public/provider-logos/kimi.jpg b/studio/frontend/public/provider-logos/kimi.jpg new file mode 100644 index 0000000000000000000000000000000000000000..956a5b58b102ea01c21cd7a9e8697f42c3af73a1 GIT binary patch literal 15554 zcmV;zJUzowNk&GxJOBV!MM6+kP&gp2JOBXj{{Wo{Vfk&`QV>4dk5JA*BDt}SJ*}t8{t2}zvF-X|MTpF;$O>utpCyaOZt)T-yJo&;rNsKXY>pBKk$F#AB7*SKkB;_ zfDhrH#Xpt*Nc}VZpZ{d`)BYM`2YKUWgmC{MeG6mclu}hfA62S zAM(HUeXf63{+Io4?7#H?a37ifW&bb!6ZUiaXaBFjAK|~of2{v={^9-K`$zwOtp8^p zx?aX#t3U5Pzthcx<{9~haS;prFp_Y5Rrhlz$yj6P3F3W&_RO7Xy03=u7Xa&|@#YF$ ze=zGJ9Z*Z|4Y*nDlhiSPiM@e<{d|7(Uvy5Sm}ELQm7U-Rh;ZH*y=#gzT>6SNh5H`{ zSLIGs^6vBk12TRG5-wS1c>(tT47`|Z(Vv_8J{G)8>q$E&FRZh4maVZPK+XXHuntM& z(a6?8>kmtPRa7iqJp7YN#UT_-x{~X_l-sX%B2C*ATh}|c3CfJcshHplrP$@y|DCejr(;R$ z8{dOjZ|e$CTg<-^tYI*pwC@`vecyvc;W*8~T(1@f*#*r!lVcI!fR0svIuPBn$_D67Cfr%Db=Tdp21 z%7$In*8E|e+@0q-BwGh%@}X!RiP^W&;NR2_Yo?3;NAPbN`UF`$H|WDBmd%MkdTpS2%H52l zAsgh7C@UJbH9^}ve@QyQV>wV>#{9gkw18vPSVxd2w}`81(Ui@CyaYwbHxo2lf}R z0hI_&iWzXPQfduUJukcw`^u1}(H;r(Lo@=xA^Td@)d1j+i#}3-u2EL!Y6eM@CN|R4 z#R2D~{e{)ey5juC)FYB~<#eMXGJ9rF5bkHwQgT|E8lN0!TRqztxMlFugojmFBmS6g zcryg zp$&WlStW3oVhX4PG~s>+RH%NuBsw95-a$Tcqh zK9kX>f@{A45Jrvuq%i}ftzoZ{ML%x+!L%Y+Ge>S`bwK+|Y}n zRY-pw^9gn-|A}7Wyxm7-I-_#4sWH=(jTt!8akaqQzzEyrOBp|K6_v-Fw4}S1-Ecj1 zp}8MG?K&>X8`-}vCz&SoQ#xtU>pf|_-KgeAFSfeMy9Mq}ghaeHbdoJ^I5W-STm8VN zGW|EB+-BsD{s8nj0piO7!CBivo7Iil*L(#6?!dITO?{(rwB;X>+zr1iXWweu(YS%d zIdVFD6p$a}gV~`p9y0(C=fNUbG;`s?33gd^z2*>wtk$BUun2#b=KZtn#1dqlEX|KvKLa-Y7sNH=t7{ zq!9HJFy*175&`4Xc#u;?gI2$2!$S!k_jtbeK!6V4O0fQR5Y){e4bi;7N`im6R?erZ z7{pF_uB?-k0E142U)}_n&mU6!)v8qXDm)nbGq%UW z^5){De@H_kSIbxG21?L))-njfm7Wqk9qa_1VlQmI7?em|_{2_CZxmv_HD(d5dc+_Y za+c&-b3XO$5K#akBuClqf*Nm5GMZN&(sik3Rk$9r4>{vmP}0qF$2x*TBS_s)baR$x z#@29|MQ6W{7@d<)eTUMpebpPwAZ#8_K! zr+5GW{`;f#Kj&{c{CvNE_aBRXB?lGn0qq&eWnUbK@070=^G!fLt2z%`a=oS*>mSLs z%h=flwfemD>3Vto?Ybt;SO&_`eh$r^h}>@&h6UPZcr?NW<^1KEQ6Ag%%CpidDvfY$6%Tyi_A!DE<0d`=jYvWdxw|HQ!mfZ% zg1vr&_2hy|<7K3RAwpay~S( zt+A}J3Yp}9%(H%%(b7-amq@U|>DFh>_PjLb?|BWDko!f?V=1Sj_f?5h}NJ&@uMlM~I zpY?AdD$!0Sp(P*R1te0^+K&<`VDYD#)s_;Me%=u4tS+(77;S69q;BQVNOFTNO;jC- z#Ahi=fjJn7SB_#pz#fKg$wR?fuu zXzg)wYH>Td?Swa#LlwV8%Y++`Do${43zjRI`e}+?3N(INziE?3`;ff+=p~|xB3Gyk zyZiYw9@#9S5v0L*+e(sdg#Bs`YMjGW zpTO^_$Mr`M{>xbazAlgzxhMG>-6+??~KEL-rX> zk}~AT@}DAOm5~4|+&1SVzu$R%^UZKBXfkn?$9?qm4ZyKElw+YgUUq_)6X!v;7ag%M zs~N3jas()Ski-1-wy%p%>fa<~U}H~N9za{%3`2(iW&8t9;2uW%J_$-z={EjFHCcUQ zk(u~KdYG7of%C#LA=Gs}eUc`Bw?}SMBxQ#;L&`LFbX0BWp;Ho)1!0tF+-K?fPrH^$ zx^*&3lVL!yK@r2(cG^QfQf?eIMZlrf=wkLyTvo^aQY~egsj?-vR>3n75f&i-0Q)?L|B3bKp(eh7%p)jBpH4aUO`?hGP z)^RVvaR;MaKMs!?Ugpl`Y4;mb*CqrC843100}>W z7fvDvuuL<;8L%?xw+HilX+p3j+&sl7FhT?{g`ksbbHSS|i z1pn-)-DrRRjB(}$svyld=rv&Y;hQ!)0!{$0ee+KMX15s*`iSA4DeQ5=EQ`F)+Bj2j zdFm%;+OXTE+bJhN+~}HkD|a8{?~fykFaVH!YBcXw%1Q?6^n^zp#SGnDp2X!ct*P@_ z8H`yw$U*#<|1v_%A_4&_xv49%IfH44S|$#gK_b+?;{DuT^(p<42ka~#cwrP2(ac4m zxAsvr+t1s*^`~HV`t)t`#|G6Qu96}@gUe7>In)pku*CxcT*==m41-?la^DNqe4k5} zC$2>c>SwFQM?b)rBXTomWsTuQ)c|pd(sgN4T_zL+-T2G~;v{HofW-i#65ONbROld+ z<{2fm?chGzT!;qQ{71vNy1mM38el2f*Bb!FVzdV)W-!L?&e@=xhTHf+{&((68rIKa z(tnKA*(Ac-E3ba5^o=i>!(vEF1K~8T-j&V2DjuHJQ9G8Q-GBlqf zM?j+TLh*E0;N2?fAN?5242Jb)*+M1;dP@z#KWEy4N@ znjVv`aE{B4(PRAS5E^M44#&+!!oSujK>{SePRjI4Na`gBac~>)hq*~2sBWRnGSSLJRJrlnJ=|+(VvuJ~EG=Uc7%t{N`e}SXo8o`|hlq|Nt zzCTAVsyCwqvw$!u+LHjW*V_qRcVS>z?Zc$NAz9RTA-(R1DIjbzdJ&#@O^US3K+VCz zyMacCkWaaMunDP_u=L5vfF1FH_9;Lor=luj6G4b+l^3caWCQcxN882W{B+;ob+C*Qo8uEc=)nUZ2_`us;Ejb#RntTEw+(*cC zIj#nbvZ=AWm(3S>m`AX>c7`T^ZmCR9MnG0f$pZVX>3%!YwF9dm(^A)5Dnwbi=7@nX znHpZpnfog4%|IB?n)e6g)R{xirfg~{H5Q6|XYkjs$<3;O8tFY0WaGfPQDtG7pyX8sOm=HIJ`YK zOm~Cn1w!8O7&b)JcB*?g*KeU*jLCrfs-`;~fd=C)eImRT52ph-31{5sN-uyvySy1v z>*pY6IFI*bvglET++Nffa_*dyG;X|t1=fISv5lF4tmNu?zwhec0tZYMXTbIhXgIN8 z-dO5rFa%hPE}v+~GNZ(Tcl+o6ZAwajW0MN#w3%B(&1tGg+KE1aOVMN1u-e+4y^jUx zF_gxs0w=G!f#zJ|>o0D#DKQ`p0#O**$8Z2Tne!zP{folc9eMb%jWSrxTM~|%c1}sI8C}8M zDGI)hK<8linh1RFeHHMw_Z3LymBBChPqD~vn^ggpTp&*WKJ`e8(SZ~{5C-4(!dK zS+MQw&TEgZKW)-^a|Ul;8+U=Bo94L%#*s76UCmG1$~=1p@aBiB1}_eBMf^aYf*4#^ zYU;VnsN=u|LzUVFVVbTlrSx-XRTf`#Nr*LG=JJ7_Uq~#5J^%msAiBV@&fb*5kWGJ? zC7yhi!oedTTV#_O8m~f+0`DTT_o2lyj&$H1NM|wMniW2j$-)EP7cIiCq-zxB3o9d#2f)r8zvaHrskt9RC!-ZQJO%Zjque>)93V z*JtTAmPbY{n%oPLDbd$3vO5QxW(8hYVZ0$>9IAMF_bLzh1KRVqSORgDJvB@a>{+8X zH6e!fMQl{)Q*1@0s4zv+b7y#;WMtihoTntS%|D~9ygs4cBS0L&s0ag*FN|E1g?Z|p z8`Hix&?o=?)qZ(kAoB9%I;WR~)_MfYVCQLJ6pmv@P9^^r9$x$1z}NV4=PuI9q<5+} z8hyAs&;^$b0_OHDzrVn036tHw4)gyf`_nS(FV5~_>V6(mAK6p#cZjv5m;eC;b4na% zm(2{`0xJ~-Wnes@Zwah5fsUy&Gk_)l6t%2;nxxgWu%0l2I|R^!FTukxHZ9u-FUAD_WRj#cbeqcgi1gsI)Olzt<>&qyssk%1-(NoyjA%vTtc_v9MQI0LfkC1H|(M=RJLKHD!rrsQiu z{o$Vcqdl6~oLn~?I)mLOw|!^I0Jus;K5Z%qXQue(gRn{Jqo=|aRFoP>d8=#8`O>g# z^7a#EZ*RweiHh;{;pFn?qnG9rHynf2*C>Uz@VM$cu=38yg`@o&fQV+AWO-eCmqN(q zh;K7O3ga23en)spA&-`sY`#}#y%4MWcDuHjmkm;W!yqM3e@4Cgc=NC!MEq1jDV#Wi zBFi0<af3NN$j;pDb+1MKSHSRZUC!yWN9T?~~En@ke z!IL28z?)`8_<4y!LUxotzR6u6MRt-Gi^V(aQaSHobm6X=ciTyc(3r1^ z$L&9YjBjcT9u8JK_Yx=u1D-DiyfCUd{pS| z9}TH5>NjC(byO0hZI_E{ z%-FVqzZYy)i}IXEr~Jds9CuN6h-djJPhf zqBGiB5E;oXT1br<gY>B~=?JO~vZYB<`zv;}lAuVCOh zk*!?%!gIii>I29bE`{yFBX6-5^%jh|rxQOJ;t z6V(kBwXabBWniXi-0>?h_y^8x&&z4tc6z4L3&!@7u7<}D{IR`TitUz)x`3c2?>r!_#7T3=G(GlEsf@J|n zfuQ#_z-w&X@*rlnkuuVWfgOXU8teLF%LAjm>&3BDWWHA{|7%IH((I01l{|i18T|cm z7=X*`GE7G+8Cca>E^qI0mh=ZC*(S_zbAY;X_N)?X9Ed70&AXNvp&x{jw=bBJhJY>D zgKc|D9ZH^4DKS@P%hV#Bs|EX8j6w(Fanj#tG$D#g8tN1KBXJ!PRj$6QAPw<~NjV>q z{$x+o+Ul*2zd8UTLeLp5qdoUkC`p&pz&ZJmDMj1`uL*~KH>2*;oIX#|Ck^kXQ0FHC zQ+J7s`WZTEf7kp%fB5JSHa^q4(H_0v?&p(o506B!>W0kuwhgjh+k#s;hgssArJELt zU=Y3D(&E4qo@wC^_Db4NcF7K@M!UvwwyCB1TlXE8{!=Gp%yPqm%^g0<0X}qN0Yzz0 z0(j+ZY|u8Eq{9>CRNXt~o#Al=RD6L(Hk*3hOp^=p-fJsR1~z`3<)*Wc;yMh(UFsV* z>Id>eN3oen_SS+(afO~tKRE~mje*B;!D=VjO2?&j^xj={7TWqRs8x*Fh_x1XjPPqn zVyQd7R2E^M=p*D3q!2_bOe)$@%F}M@kT?xIsiwvM^j0YdzEi)*=SAJ(-{VVKPiL$4 zO1q-K=ATSiFs-0g(=V%?X!$SMk@ypCSjbN2{&bnC~onuKTPIEZRg;76;;>_`O z=^CzRwFZ!{bLHo?_)Xw56v_N4;HUt4zb>#)Feb2id-D(Xx6F|kC`d#iR{7gbN2PM= zB}Ic2-u3tVd29Ihx!njmnAxh?aSb?KFG&@cmNycCN|`}=OyaW_j_U>y=&xWJ1uUE# zu#C|l!|0;cEYbv@U%etD*e-z`@04X+r{23*4EX2%pc(P7bCQpyisJpPlA5r>T$5J* z?BSUW6@OLU;af|JnKQlSt9!am+9WOYND-|^nBV@Twvkq1AJDVrb2Y1L{^w*@kuZ6N z-3Z|`fjZKv3{T&NK~)sWh}Thfo~pOTMh%;?3`{uyk&&ty!H*GjZ|Bx-{Pw&sXMX6h z5K%-5ZK^y?vW8j7CdtSZ4eWx-ZG3O_KB_8}!(1vuh@spwZ4oGn+|oV(3nG6N{eNR7 z$JsL`m~ZUF*hR5O-(x!WKOY{ysN#+N2wN@M zsK0O5S=BSn$K2#xcEKefZGB~)1zlB%3i|nutSw<VhDO!d)h63@OM%A5~mUa9As~DK!C`- z3fB|q&U{RN0*onG07o9CL(NC7qTj>cPqU=DS^hp(K5jbY#PJh38-&P%X!UB1zj-)d zgiU9>_pKsPRlymx zy`Gd8?(x(4BS1IwJQ$dq>G5+Fe!1yWajIh1iW+G6@|_dTuD?L5k)9KIx5CT=ABzGv z0{tB;z*WAjHUTZsD&EmY#3F4;%i?Rvcl z`e#`VGht0qxx@;fmIp<PcTE+A*U#5q;qH1kvxeVKi8-Ju~2w+lC%|VX*sxhm@iy z9SW-u^GG}PZNc}p+Kwb(j7@~7AE{3Zp)#acdM#NG&59$8ewG~VWa=PBEZrQJn^D*2 z;1-V6t0t#z(!3w_-4$wW5!1O1>uFTtH#<}c)nw&Q7yeJo_26P{b#L8d<-e26gv@~UeL=w|L zSZ+|hgxEv>5z@hs{zpZ~nyg!+S9hLP4$)#qxl@FV-ZPP+Ja1xZE-ZJRnst4GNw#kc zl~-u~L-y=8(AkBUtnIQRwB`l(0k&};$z>A1`MMw?hw1G1k?^z_9R-GMdMV|(0yH{K z)u}TbDX@T)6U;gAZK^-%WUDi@y+Yz?^~)-xjY~5FNj68Qclz(e;|(RLo250i77>0@>Gz~5xZLXrqXMzSX;s)Q5cPJ;rU78=YPrZp z?xq*qQf@ICX1vCh-wRC0)-yA@G$*8G^d&<{moi>fmNRnS9BXW)yRdvz=bAnsfgmXB8Kcj2AHtHOta=f&JU)_FHw!n~deZ2LY z-d-1ucW53n*1K);e^tJwe$6cO?p(*!B^u?y6S{cF!=K-jZew$5u$Ihp48RQYT~YVn z2ln}OhLVzHw)+8re31Xj7l2d$HMfw`Ll$6R(_)9d5sStVc>?Y&2AcVH5+5QiE+~U} z8YiWN>QVD3sGRxDKtg~|aJVWOmelB9rkZY$L*pS@Z~dU}0M3&5W1RHErD2jyI5gl@ z+P(|i(SZbPv+QN~TE>!r=1!!1haf~%xte|C*{IzWW~;Y@FU zwd|q`TWm4f0r6*LCxkI$>D1ZX*}C1@Y&jB39zrO;Soa7eFI8Gk9Bu9btGt* zq-Iq5w0_EK9yYt)JEMSn(6;ds^DCY-E=)f_j1tq_DTyiI8&kNh3&TW-z&B?IrhX%w zrf4y1I`8q5V*>P02*G_7PWLoXsb-X(Pn{=luaK>gy>mBHPB~mD^Z5gMCn5<&+@L0U zl+L8$w}>O6C_4q5`IJ}7v%_fg(TV8(y-Iz|LR*Go#STVr`oZz`kvC6~JPjLbEm;ik z-qrSI#>^dpr>1~t0yP5W$uCJvI&8;gG40M!KR``5aKW7!YAa-@Mfz1ZJUpjt^l!#> z@Ywtv`?)3`VTwwZnlE}{|AIYm7)iO5h4ckcq#=sCO=6IVd-yDg4|hiL2RfLJu7zxE9~{0^i9wh zVvIS7I8-lLC?bhWd?Fh=1@`4F`_dcL6_u;$K*W)&lRDQ3zGoT>H8p@>+p zda@|GAZ|$GvisIqjeS`DV#3Y9legm#%W+oqLBCcT_bBsg+*oW+BdRNu@QCgn`yZ++ zH~`lrdEq+of}OkxIr|KR7BuaW<*t%ZoyYef%4#k&3e^v$C$?E-9M>)3v`PTe2k{9v z(M^Ee3d>b*qbk)wL}O~K<6`N+3#j+ND4Ad`zvwyDv8~%LwGYZg7L33~M6)6nU)+9Y z%J^F4aLD~CEOHc9(`*OXJXi73JiFF;boZ!wZRxvvGSq_^2ma&zE2T?>F$mu5zyw)Z z85Pl+OdBESyDOcc^^kuTC{|<>U+2nK6nVf{NIYPbyizhrho}v@?pWbp+EaX{&M`r| zu|f_zD8Ia~NEqTrMEJQm{}oST7cEIkMP&knD2lL~rM1dIEGMLQ9s8RU^{TvS+|q_m z9||Q(>w-#I@tmh@wYM2|&?-m#f?ii4)OfVX?f3I5y&VtE4G7j*w#M2VnD*=I?Ur6g z{Sg67x$H@~q(vMyU*Q>_Vt>ix6J|kp$+KDldQ;uRAEtA_8u9p3u32Iyl~ApF=vcjM zz>a&+7p|M_`$l?Qi|CbXeM7Rywn~ezg{~BBVd5vgaj0=-Amg- zDzboRxTE{}(^udh)oTBr4x_epkj|X8&|+1m{m&yhW2cS$Zb;t>B>xWoIW@yLT(~pt z?7$q&m&^=3im}0W`T%T7rbdJR|4d0i#OrP8HKLhd0KV1{Sp)Dg5qCo@ayF$sG|*dE zD|6)^Ud09b&y02W>V8^b z)?lhs0Ee?oLRh+huwqHn|kT1qIQPFC$r zGu1ahatLG{eWhzm=A{|^X+B51Gv*i?MLhmTQ8;85qw4i@X&Fc@gj*VW-27h6%{b=? z`qPL)o#tkuXC+@{$%U_aA_g|^5d$s3<1mQ+tRjfuMw{0g)Q7tX+zr1rOdyMPmQ~C)c*#+~kcqkJ|_f2r64lOmtqz>JZ>Fp6d9Yn2tQ~b<=tN zG;uBTKYrTt@*~t5nw1oQ0eL>R?58^t{-`vzQu~%>WKJT%P-l7wdgPbN`>cgAlgnTy zI&t_kh1alFasMq6-UD|NO=gnE3$kiXjcOMGyqWKnd~xG-FK&?Gb;yulh%}6qi@&Rc zQ}&0OBJ2DJuSz;X=hXsBCTTZ0qPhaBD%Ln&L=6FRk>(QV9+JLv`+pmcgzhvxN$yO1 zu>_MFG9dxVoiiuY@boF4{5DTgKQjxy^VF|sp2sK(5+pqoxDhl9{{C=_%L=0N9ZH`y zJCY(Ox5&nA)xe;ZEekDC#W7l(-9Jrjue>ERY>~A_fI+4qaBeYF)w$?pjrB3qJI4?!zk4&;H4I&nqY3uqjSuwc%4Y)Bljb;VO0yvo z+sk)-Td|VK(8O?78b_J4xU%ixK(G8f3rIj=HqzKq_LZ;m8erDq$}OusLNosCcuf?g zu8-;Rq#>ya6JmsMoOIr8;U~yN)b1xlAW*3qGMr+-#6vzVR;%vMaWYjg=0^Q6u(mtx zVKR@-%%P4rN(|+eDhYVds>&u*=APw;K)cub-1zMA?5^Q)lNuhT{5*H^;Uh|Il93@Z zy2x-172tK~-A0{=JRLDm1mES?S0o~jwBW>e%Lk=i%NxUN5pIFvj;L@m(5V2DmouFzJkxo!4rj=^=HGJJ6MVh2s`0*( z=JXGbohpn@P^%Pv=gs8P3$G2urP;Jy9g=wVRG-TS5FYxQ)2{*J3EM<8o zsfP=LcGK4pD{21#vbGxGbf$C!bK}uo+`%`n{JOtAMGWTdI@DY(Tzr zKy5dIBpm%`VO|*|iKAC)lX1U^*m2vo`L>DBKPmFw=qG6Nc7$G{v8{8J`K*fg_ZW+C zP_LVQi@e!!MLi_GcIePamxHH-h}bGuGXzbC|4F-$ zvA~@ac1q#ffz?q`pKSlOaY^C}k_4v5>ZOxf?{1GMayW)>2)Z%}!9m15N9}5Kq z87@(wao3DQ4i#aa_H1vCtw6EV=MkKAc5vSbu(MwwJ)UqslXb+O?=66#@k zDy^Ub4$N=eS`jWp1k_Y{bUhYl3JO%ah?__v*46a`pU;T=YkiY;{%N>Mt;UuZcyWlE zsx~6GXNYLAVvJ;(*!pL=$-gb2tZyL^4}Nh)L!-J~h?T8(I25?sIb!nA?qssftb!lt zx?duO6VN93rju=g!(a`r0|fleBvM$JW)>c3pMXR@6a!J9br@Ulm98SlqIMA-3Z-9$ zhNQ|KE?JXT+8FrPRY(bds5bdtDgJV_4IvC4bPP*gB-r!1VZ5*|>HwMJ^*1-&j5S~`))|hi{OYbw#`sBv zBzgvVGi%Z`KD9tBvqNLNZA55gjwUw%w4lW-xsNTV-PksM#QmJz5W?C%=)i!_ckvbi z6c`4S=-`RoQ+Er=-gfD)rIN)H7F{4fA7X&(7Fd=D)Npe%ec=PS3rGSKyP~DktfF5V zg=4r_XV>fh_HK!cT&)v$98vFrb4*a0(79o%hN<8=8vFJ`{@AqoF@x1^Ryn5Uy?Wub z`iFz5V>*m96d94C0cIL!-Ez;tKK?s65YhLFcsgE~LI#EM76s!q5^IU@Y7f{pTO~Gq z>PXf=%(*$@J!1$qHaPof4eLl_r~n_aA~?@3UUrp8WV zWDoVu#YZVU`Eu|$b(<8y1tNFoqpN>_wjpBEya4^3g5K_u-ETVqI&2}&IFTVS_&Q7M zup1Yz6L>klKQdNW*~s5qkj%2(_q&XO&%*;hzm#h5EE7!^Xiw#5PnkJ&dj9Te9&_x| z=+1AW*^|Y8k}v4#aK`h=bo&Y!lK|2lFIz&j8b!@MWSS|j&~|6i3OWDollFa<=4K+d z)-jMqJBAd=(1aYJep0VXTeIV%DclsId2VM&LMebtFWHJ_*5iDuvG8%+fsSdG@2mq- zhX#C8YHP5%_tq}cT581!`=pfV6XDX#Jy};zJ37Uls+7s^|lLe)TN9w)=_KkV}1!2%TMhhY>kx^Qo#_KrGl|sxlUAY?iWLmp7ldhb+Fi_1KRTuu`TF}N?(Ayyf`kj&SffthTLE!G$w`RJV1WEk{8U{ZaRMA1A>?MfV~h7} zfUAzv@$r7@*XeWZ0g~d(jvYNx>ceW1YtZc^6oMtoCQ|ZO@{q*z*wV$*h42MFwa-KL zh?vAUWq70yqQL?-;k4`amH#_~_tHwD!_bU=NIVsN31;2VP&bSdOLRzRSurV^sf1(G zJXJf(=Z3!^APT-~aOB(#bR1N@eOlA2kS#%y!~{xq5F=%;tx*r)k$$K~hiE~R)r#z- zh}`fVDmV{EI^Ty@Qjh0GN6hvbVoGP2zU91_%-eU^b3D<%+!&K8IWCeF2!ri7xVq;wW z0&piGTzc?p3yer6%ber5_9R|hp1d`MH0OXm6zmF%E~Sj%B(BJ)xJJDP%YayXlB~{KLZ} zIt-2WJJ~QR_~Leg>M|*ofD)+`+WmXjQXhg{3Ehc(3$q>b^2|-xHPR;g$a zT$&xJu+_6f6H4W{son${I`Jul$+=na8z6%cPP*3sQ{B({q2Uq z_(B(X;IS0GOLi}0W|8UW$V$rJo{Kn@6f`+g2UORhCIZ1%CPXk9)KC#DFnh}ILW5x1 zt8`H{5ua16rE`$efbOc)JX$Q%LahsA@*;Ml%{3k-G<*dvB%u65Q(RT}0mX;;I+i0C{FItFw!%&fb1p%Q zDj@UzRB~y&Gr7{st)Ku*Pb?Wid`7P%J1x1jUCrCjb7~lK z)xeE7R+krgerfP-$7Jv)AaX(QjzW2HQD8%wpnoax7#W!~q<-3SNxNancq}&n#w)^C zm=leGh&3KUh&MV@Icp5HxQDCD@ll=6Cy^AZN^LhND9qZj;r!THp}14Vy}NCv$VGtWWq$Mp|ZT@ QpM_5wk7l)q000000FI(=XaE2J literal 0 HcmV?d00001 diff --git a/studio/frontend/public/provider-logos/misc/meta.svg b/studio/frontend/public/provider-logos/misc/meta.svg new file mode 100644 index 0000000000..9fa656bd6b --- /dev/null +++ b/studio/frontend/public/provider-logos/misc/meta.svg @@ -0,0 +1,19 @@ + + +Logo of Meta Platforms -- Graphic created by Detmar Owen + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/studio/frontend/public/provider-logos/misc/microsoft.svg b/studio/frontend/public/provider-logos/misc/microsoft.svg new file mode 100644 index 0000000000..5334aa7ca6 --- /dev/null +++ b/studio/frontend/public/provider-logos/misc/microsoft.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/frontend/public/provider-logos/misc/minimax.png b/studio/frontend/public/provider-logos/misc/minimax.png new file mode 100644 index 0000000000000000000000000000000000000000..e9472c676dc3af879f12d31ac0c66c0c0f211640 GIT binary patch literal 8665 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 \ No newline at end of file diff --git a/studio/frontend/public/provider-logos/misc/perplexity.png b/studio/frontend/public/provider-logos/misc/perplexity.png new file mode 100644 index 0000000000000000000000000000000000000000..9845765c7f72d4f14797bd7c7bc1457149716453 GIT binary patch literal 6632 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 Q1dz`{zs2)P=ZL=hZ!LJPX#fBK literal 0 HcmV?d00001 diff --git a/studio/frontend/public/provider-logos/misc/xai.svg b/studio/frontend/public/provider-logos/misc/xai.svg new file mode 100644 index 0000000000..0c83eb3d9b --- /dev/null +++ b/studio/frontend/public/provider-logos/misc/xai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/frontend/public/provider-logos/misc/z-ai.svg b/studio/frontend/public/provider-logos/misc/z-ai.svg new file mode 100644 index 0000000000..28ca7280a1 --- /dev/null +++ b/studio/frontend/public/provider-logos/misc/z-ai.svg @@ -0,0 +1,215 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/studio/frontend/public/provider-logos/mistral.svg b/studio/frontend/public/provider-logos/mistral.svg new file mode 100644 index 0000000000..40c2591b31 --- /dev/null +++ b/studio/frontend/public/provider-logos/mistral.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/studio/frontend/public/provider-logos/openai.svg b/studio/frontend/public/provider-logos/openai.svg new file mode 100644 index 0000000000..74d9b1b44b --- /dev/null +++ b/studio/frontend/public/provider-logos/openai.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/studio/frontend/public/provider-logos/openrouter.svg b/studio/frontend/public/provider-logos/openrouter.svg new file mode 100644 index 0000000000..4a4968b639 --- /dev/null +++ b/studio/frontend/public/provider-logos/openrouter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/frontend/public/provider-logos/qwen.png b/studio/frontend/public/provider-logos/qwen.png new file mode 100644 index 0000000000000000000000000000000000000000..67d2258f407d7b06323959c35f7c42a50c641136 GIT binary patch literal 117172 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%0Dlq$**qMbf&m@$hSGw}i~1)Qt;* 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 GZ3O@>GoEMw literal 0 HcmV?d00001 diff --git a/studio/frontend/src/components/assistant-ui/model-selector.tsx b/studio/frontend/src/components/assistant-ui/model-selector.tsx index 22bd7412ab..b4f7dd08d2 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector.tsx @@ -13,21 +13,71 @@ import { usePlatformStore } from "@/config/env"; import { cn } from "@/lib/utils"; import { ArrowDown01Icon, + CloudIcon, FolderSearchIcon, Logout01Icon, + Search01Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { useMemo, useState } from "react"; import type { DeletedModelRef, + ExternalModelOption, LoraModelOption, ModelOption, ModelSelectorChangeMeta, } from "./model-selector/types"; import { HubModelPicker, LoraModelPicker } from "./model-selector/pickers"; +import { Input } from "../ui/input"; + +const PROVIDER_LOGO_EXT: Record = { + openai: "svg", + mistral: "svg", + gemini: "svg", + anthropic: "svg", + deepseek: "svg", + huggingface: "svg", + kimi: "jpg", + qwen: "png", + openrouter: "svg", +}; + +function providerLogoSrc(providerType: string | undefined): string | undefined { + if (!providerType) return undefined; + const ext = PROVIDER_LOGO_EXT[providerType]; + if (!ext) return undefined; + return `${import.meta.env.BASE_URL}provider-logos/${providerType}.${ext}`; +} + +function ExternalProviderLogo({ + providerType, + className, + title, +}: { + providerType: string | undefined; + className?: string; + title?: string; +}) { + const src = providerLogoSrc(providerType); + if (!src) return null; + return ( + + ); +} export type { DeletedModelRef, + ExternalModelOption, LoraModelOption, ModelOption, ModelSelectorChangeMeta, @@ -36,6 +86,7 @@ export type { interface ModelSelectorProps { models: ModelOption[]; loraModels?: LoraModelOption[]; + externalModels?: ExternalModelOption[]; value?: string; defaultValue?: string; activeGgufVariant?: string | null; @@ -53,11 +104,13 @@ interface ModelSelectorProps { onOpenChange?: (open: boolean) => void; triggerDataTour?: string; contentDataTour?: string; + showCloudIndicator?: boolean; } function ModelSelectorTrigger({ currentModel, isLoaded, + showCloudIndicator = false, variant = "outline", size = "default", className, @@ -65,6 +118,7 @@ function ModelSelectorTrigger({ }: { currentModel?: ModelOption; isLoaded: boolean; + showCloudIndicator?: boolean; variant?: "outline" | "ghost" | "muted"; size?: "sm" | "default" | "lg"; className?: string; @@ -90,12 +144,27 @@ function ModelSelectorTrigger({ {isLoaded && ( )} - - + {currentModel?.icon ? ( + {currentModel.icon} + ) : null} + + {currentModel?.name ?? "Select model"} + {showCloudIndicator ? ( + + ) : null} {currentModel?.description && ( - + {currentModel.description} )} @@ -115,6 +184,7 @@ function ModelSelectorTrigger({ function ModelSelectorContent({ models, loraModels, + externalModels, value, onSelect, onEject, @@ -127,6 +197,7 @@ function ModelSelectorContent({ }: { models: ModelOption[]; loraModels: LoraModelOption[]; + externalModels: ExternalModelOption[]; value?: string; onSelect: (id: string, meta: ModelSelectorChangeMeta) => void; onEject?: () => void; @@ -139,6 +210,20 @@ function ModelSelectorContent({ }) { const hasSelection = Boolean(value); const chatOnly = usePlatformStore((s) => s.isChatOnly()); + const hasExternal = externalModels.length > 0; + const chatOnlyTabsDefault = useMemo( + () => (value && externalModels.some((model) => model.id === value) ? "external" : "hub"), + [externalModels, value], + ); + const studioTabsDefault = useMemo((): "hub" | "lora" | "external" => { + if (value && externalModels.some((model) => model.id === value)) { + return "external"; + } + if (value && loraModels.some((model) => model.id === value)) { + return "lora"; + } + return "hub"; + }, [externalModels, loraModels, value]); return ( {chatOnly ? ( - + hasExternal ? ( + + + Hub models + External + + + + + + + + + ) : ( + + ) ) : ( - + Hub models Fine-tuned + {hasExternal ? External : null} @@ -171,6 +276,16 @@ function ModelSelectorContent({ deleteDisabled={deleteDisabled} /> + + {hasExternal ? ( + + + + ) : null} )} @@ -207,6 +322,7 @@ function ModelSelectorContent({ export function ModelSelector({ models, loraModels = [], + externalModels = [], value, defaultValue, activeGgufVariant, @@ -224,6 +340,7 @@ export function ModelSelector({ onOpenChange, triggerDataTour, contentDataTour, + showCloudIndicator = false, }: ModelSelectorProps) { const [uncontrolledOpen, setUncontrolledOpen] = useState(false); const open = controlledOpen ?? uncontrolledOpen; @@ -266,8 +383,21 @@ export function ModelSelector({ description: tag, }); } + for (const externalModel of externalModels) { + all.set(externalModel.id, { + ...externalModel, + description: externalModel.providerName, + icon: ( + + ), + }); + } return all; - }, [loraModels, models]); + }, [externalModels, loraModels, models]); const currentModel = useMemo(() => { if (!selected) return undefined; @@ -303,6 +433,7 @@ export function ModelSelector({ void; +}) { + const [query, setQuery] = useState(""); + const grouped = useMemo(() => { + const needle = normalizeForSearch(query.trim()); + const byProvider = new Map< + string, + { providerName: string; models: ExternalModelOption[] } + >(); + for (const model of externalModels) { + const searchText = normalizeForSearch( + `${model.name} ${model.providerName} ${model.id}`, + ); + if (needle && !searchText.includes(needle)) continue; + const prev = byProvider.get(model.providerId); + if (prev) { + prev.models.push(model); + } else { + byProvider.set(model.providerId, { + providerName: model.providerName, + models: [model], + }); + } + } + return [...byProvider.entries()] + .map(([providerId, group]) => ({ + providerId, + providerName: group.providerName, + models: group.models.sort((a, b) => a.name.localeCompare(b.name)), + })) + .sort((a, b) => a.providerName.localeCompare(b.providerName)); + }, [externalModels, query]); + + return ( +
+
+ + setQuery(event.target.value)} + placeholder="Search external models" + className="h-9 pl-8" + /> +
+
+
+ {grouped.length === 0 ? ( +
+ No external models configured. +
+ ) : ( + grouped.map((group) => ( +
+
+ + {group.providerName} +
+ {group.models.map((model) => ( + + ))} +
+ )) + )} +
+
+
+ ); +} diff --git a/studio/frontend/src/components/assistant-ui/model-selector/types.ts b/studio/frontend/src/components/assistant-ui/model-selector/types.ts index 3da75b4d4e..4cc5d779ce 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/types.ts +++ b/studio/frontend/src/components/assistant-ui/model-selector/types.ts @@ -18,8 +18,15 @@ export interface LoraModelOption extends ModelOption { exportType?: "lora" | "merged" | "gguf"; } +export interface ExternalModelOption extends ModelOption { + providerId: string; + providerName: string; + /** Registry key (e.g. openai, gemini) for provider branding. */ + providerType: string; +} + export interface ModelSelectorChangeMeta { - source: "hub" | "lora" | "exported" | "local"; + source: "hub" | "lora" | "exported" | "local" | "external"; isLora: boolean; ggufVariant?: string; isDownloaded?: boolean; diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 417637801c..d75a8cce10 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -31,6 +31,9 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { sentAudioNames } from "@/features/chat/api/chat-adapter"; +import { parseExternalModelId } from "@/features/chat/external-providers"; +import { getExternalReasoningCapabilities } from "@/features/chat/provider-capabilities"; +import { useExternalProvidersStore } from "@/features/chat/stores/external-providers-store"; import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store"; import { applyQwenThinkingParams } from "@/features/chat/utils/qwen-params"; import { isTauri } from "@/lib/api-base"; @@ -474,15 +477,69 @@ const ReasoningToggle: FC = () => { const modelLoaded = useChatRuntimeStore( (s) => !!s.params.checkpoint && !s.modelLoading, ); + const checkpoint = useChatRuntimeStore((s) => s.params.checkpoint); const supportsReasoning = useChatRuntimeStore((s) => s.supportsReasoning); + const reasoningAlwaysOn = useChatRuntimeStore((s) => s.reasoningAlwaysOn); const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled); const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled); const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle); const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort); + const supportsReasoningOff = useChatRuntimeStore((s) => s.supportsReasoningOff); + const reasoningEffortLevels = useChatRuntimeStore((s) => s.reasoningEffortLevels); const setReasoningEffort = useChatRuntimeStore((s) => s.setReasoningEffort); - const disabled = !(modelLoaded && supportsReasoning); + const lastOpenRouterChosenModel = useChatRuntimeStore( + (s) => s.lastOpenRouterChosenModel, + ); + const externalProviders = useExternalProvidersStore((s) => s.providers); + const externalSelection = parseExternalModelId(checkpoint); + const selectedExternalProvider = + externalSelection != null + ? externalProviders.find((p) => p.id === externalSelection.providerId) + : undefined; + const effectiveExternalModelId = + selectedExternalProvider?.providerType === "openrouter" && + externalSelection?.modelId === "openrouter/free" && + lastOpenRouterChosenModel + ? lastOpenRouterChosenModel + : externalSelection?.modelId; + const externalReasoningCaps = + externalSelection != null + ? getExternalReasoningCapabilities( + selectedExternalProvider?.providerType, + effectiveExternalModelId, + ) + : null; + const effectiveReasoningStyle = + externalReasoningCaps?.reasoningStyle ?? reasoningStyle; + const effectiveReasoningAlwaysOn = + externalReasoningCaps?.reasoningAlwaysOn ?? reasoningAlwaysOn; + const effectiveSupportsReasoningOff = + externalReasoningCaps?.supportsReasoningOff ?? supportsReasoningOff; + const effectiveReasoningEffortLevels = + externalReasoningCaps?.reasoningEffortLevels ?? reasoningEffortLevels; + const effectiveSupportsReasoning = + externalReasoningCaps?.supportsReasoning ?? supportsReasoning; + const reasoningLockedOn = + effectiveSupportsReasoning && + (effectiveReasoningAlwaysOn || !effectiveSupportsReasoningOff); + const effectiveReasoningEnabled = reasoningLockedOn ? true : reasoningEnabled; + const effectiveReasoningVisualEnabled = + effectiveReasoningEnabled && reasoningEffort !== "none"; + const disabled = !(modelLoaded && effectiveSupportsReasoning); + const formatEffortLabel = (level: typeof reasoningEffort): string => { + if (level !== "xhigh") return level.charAt(0).toUpperCase() + level.slice(1); + const normalized = externalSelection?.modelId?.trim().toLowerCase() ?? ""; + if ( + normalized.startsWith("claude-opus-4-6") || + normalized.startsWith("claude-sonnet-4-6") + ) { + return "Max"; + } + return "Extra High"; + }; + const effortLabel = formatEffortLabel(reasoningEffort); - if (reasoningStyle === "reasoning_effort") { + if (effectiveReasoningStyle === "reasoning_effort") { return ( @@ -493,26 +550,47 @@ const ReasoningToggle: FC = () => { "flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors", disabled ? "cursor-not-allowed opacity-40" - : "bg-primary/10 text-primary hover:bg-primary/20", + : effectiveReasoningVisualEnabled + ? "bg-primary/10 text-primary hover:bg-primary/20" + : "text-muted-foreground hover:bg-muted-foreground/15", )} aria-label={`Reasoning effort: ${reasoningEffort}`} > - + {effectiveReasoningVisualEnabled ? ( + + ) : ( + + )} - Think:{" "} - {reasoningEffort.charAt(0).toUpperCase() + - reasoningEffort.slice(1)} + Think: {effectiveReasoningVisualEnabled ? effortLabel : "None"} - {(["low", "medium", "high"] as const).map((level) => ( + {effectiveSupportsReasoningOff && ( + { + setReasoningEnabled(false); + applyQwenThinkingParams(false); + }} + > + None + {!effectiveReasoningVisualEnabled ? " \u2713" : ""} + + )} + {effectiveReasoningEffortLevels + .filter((level) => level !== "none") + .map((level) => ( setReasoningEffort(level)} + onSelect={() => { + setReasoningEffort(level); + setReasoningEnabled(true); + applyQwenThinkingParams(true); + }} > - {level.charAt(0).toUpperCase() + level.slice(1)} - {reasoningEffort === level ? " \u2713" : ""} + {formatEffortLabel(level)} + {effectiveReasoningVisualEnabled && reasoningEffort === level ? " \u2713" : ""} ))} @@ -523,17 +601,34 @@ const ReasoningToggle: FC = () => { return ( +
+ + Cloud + + + + {editingProviderId ? "Edit" : "New"} + +
+ + +
+
+
+
+
+ +

+ Supported registry or Custom. +

+
+ +
+ +
+
+ +

+ Stored locally. +

+
+
+ setApiKey(event.target.value)} + placeholder="Enter API key" + className="h-9 pr-9 text-sm" + /> + +
+
+ + {isCustomProvider ? ( +
+ + + setCustomProviderName(event.target.value) + } + placeholder="Custom" + className="h-9 text-sm" + /> +
+ ) : null} + + {isCustomProvider ? ( +
+
+ +

+ OpenAI-compatible endpoint. +

+
+ setBaseUrlDraft(event.target.value)} + placeholder="https://my-vllm-server.com/v1" + className="h-9 text-sm" + /> +
+ ) : null} +
+
+ +
+ + +
+
+ +

+ {modelStatusLabel} +

+
+ +
+ {isCustomProvider ? ( +
+
+ +