* 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) in048d73bf, 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 in048d73bf, 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 <think>...</think> 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 thebf24e604removal: 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: <level>' (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': '<level>'}} We had been sending the legacy 'output_config: {effort: <level>}' shape, which Anthropic appears to silently ignore — adaptive ran at the server default effort regardless of the user's selection. Rename to 'effort: {type: <level>}'. 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 <think>...</think> chunks (same pattern as the OpenAI Responses path) so the frontend's parseAssistantContent lifts it into the reasoning channel. The </think> 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 <think> 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: <level>}' 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 in0a664df4stays — 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 '<think></think>' 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 <url>' 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 <think> block in cumulativeText (mirrors how the backend wraps Anthropic thinking_delta and OpenAI Responses reasoning summaries) - When content arrives after reasoning, close </think> first - On stream end, force-close any still-open <think> so parseAssistantContent can lift it into a reasoning part cleanly Anthropic and OpenAI Responses paths are unaffected — they already wrap as <think> 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': <level>} 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 <think>...</think> 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 :<chosen> 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:<short-chosen> 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 <think>…</think> to wrap and the chat reasoning panel stays blank for any gpt-5.x / o3 response. The expected wire shape is: body['reasoning'] = {'effort': '<level>', '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 <samleejackson0@gmail.com> Co-authored-by: Daniel Han <danielhanchen@gmail.com>
1094 lines
40 KiB
Python
1094 lines
40 KiB
Python
# 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 Inference API
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
import uuid
|
|
from typing import Annotated, Any, Dict, Literal, Optional, List, Union
|
|
|
|
from pydantic import (
|
|
BaseModel,
|
|
Discriminator,
|
|
Field,
|
|
Tag,
|
|
field_validator,
|
|
model_validator,
|
|
)
|
|
|
|
|
|
class LoadRequest(BaseModel):
|
|
"""Request to load a model for inference"""
|
|
|
|
model_path: str = Field(..., description = "Model identifier or local path")
|
|
native_path_lease: Optional[str] = Field(
|
|
None, description = "Frontend-visible signed native path grant"
|
|
)
|
|
hf_token: Optional[str] = Field(
|
|
None, description = "HuggingFace token for gated models"
|
|
)
|
|
max_seq_length: int = Field(
|
|
0,
|
|
ge = 0,
|
|
le = 1048576,
|
|
description = "Maximum sequence length (0 = model default for GGUF)",
|
|
)
|
|
load_in_4bit: bool = Field(True, description = "Load model in 4-bit quantization")
|
|
is_lora: bool = Field(False, description = "Whether this is a LoRA adapter")
|
|
gguf_variant: Optional[str] = Field(
|
|
None, description = "GGUF quantization variant (e.g. 'Q4_K_M')"
|
|
)
|
|
trust_remote_code: bool = Field(
|
|
False,
|
|
description = "Allow loading models with custom code (e.g. NVIDIA Nemotron). Only enable for repos you trust.",
|
|
)
|
|
chat_template_override: Optional[str] = Field(
|
|
None,
|
|
description = "Custom Jinja2 chat template to use instead of the model's default",
|
|
)
|
|
|
|
@field_validator("chat_template_override")
|
|
@classmethod
|
|
def normalize_blank_chat_template_override(
|
|
cls, value: Optional[str]
|
|
) -> Optional[str]:
|
|
if value is not None and value.strip() == "":
|
|
return None
|
|
return value
|
|
|
|
cache_type_kv: Optional[str] = Field(
|
|
None,
|
|
description = "KV cache data type for both K and V (e.g. 'f16', 'bf16', 'q8_0', 'q4_1', 'q5_1')",
|
|
)
|
|
gpu_ids: Optional[List[int]] = Field(
|
|
None,
|
|
description = "Physical GPU indices to use, for example [0, 1]. Omit or pass [] to use automatic selection. Explicit gpu_ids are unsupported when the parent CUDA_VISIBLE_DEVICES uses UUID/MIG entries. Not supported for GGUF models.",
|
|
)
|
|
speculative_type: Optional[str] = Field(
|
|
None,
|
|
description = "Speculative decoding mode for GGUF models (e.g. 'ngram-simple', 'ngram-mod'). Ignored for non-GGUF and vision models.",
|
|
)
|
|
llama_extra_args: Optional[List[str]] = Field(
|
|
None,
|
|
description = (
|
|
"Extra arguments forwarded verbatim to llama-server for GGUF models. "
|
|
"One token per list entry, e.g. ['--top-k', '20', '--seed', '42']. "
|
|
"Studio-managed flags (model identity, port, context length, GPU placement, "
|
|
"auth, --flash-attn, --no-context-shift, --jinja) are rejected. Ignored for "
|
|
"non-GGUF models."
|
|
),
|
|
)
|
|
|
|
|
|
class UnloadRequest(BaseModel):
|
|
"""Request to unload a model"""
|
|
|
|
model_path: str = Field(..., description = "Model identifier to unload")
|
|
|
|
|
|
class ValidateModelRequest(BaseModel):
|
|
"""
|
|
Lightweight validation request to check whether a model identifier
|
|
*can be resolved* into a ModelConfig.
|
|
|
|
This does NOT actually load weights into GPU memory.
|
|
"""
|
|
|
|
model_path: str = Field(..., description = "Model identifier or local path")
|
|
native_path_lease: Optional[str] = Field(
|
|
None, description = "Frontend-visible signed native path grant"
|
|
)
|
|
hf_token: Optional[str] = Field(
|
|
None, description = "HuggingFace token for gated models"
|
|
)
|
|
gguf_variant: Optional[str] = Field(
|
|
None, description = "GGUF quantization variant (e.g. 'Q4_K_M')"
|
|
)
|
|
|
|
|
|
class ValidateModelResponse(BaseModel):
|
|
"""
|
|
Result of model validation.
|
|
|
|
valid == True means ModelConfig.from_identifier() succeeded and basic
|
|
introspection (GGUF / LoRA / vision flags) is available.
|
|
"""
|
|
|
|
valid: bool = Field(..., description = "Whether the model identifier looks valid")
|
|
message: str = Field(..., description = "Human-readable validation message")
|
|
identifier: Optional[str] = Field(None, description = "Resolved model identifier")
|
|
display_name: Optional[str] = Field(
|
|
None, description = "Display name derived from identifier"
|
|
)
|
|
is_gguf: bool = Field(False, description = "Whether this is a GGUF model (llama.cpp)")
|
|
is_lora: bool = Field(False, description = "Whether this is a LoRA adapter")
|
|
is_vision: bool = Field(False, description = "Whether this is a vision-capable model")
|
|
requires_trust_remote_code: bool = Field(
|
|
False,
|
|
description = "Whether the model defaults require trust_remote_code to be enabled for loading.",
|
|
)
|
|
|
|
|
|
class GenerateRequest(BaseModel):
|
|
"""Request for text generation (legacy /generate/stream endpoint)"""
|
|
|
|
messages: List[dict] = Field(..., description = "Chat messages in OpenAI format")
|
|
system_prompt: str = Field("", description = "System prompt")
|
|
temperature: float = Field(0.6, ge = 0.0, le = 2.0, description = "Sampling temperature")
|
|
top_p: float = Field(0.95, ge = 0.0, le = 1.0, description = "Top-p sampling")
|
|
top_k: int = Field(20, ge = -1, le = 100, description = "Top-k sampling")
|
|
max_new_tokens: int = Field(
|
|
2048, ge = 1, le = 4096, description = "Maximum tokens to generate"
|
|
)
|
|
repetition_penalty: float = Field(
|
|
1.0, ge = 1.0, le = 2.0, description = "Repetition penalty"
|
|
)
|
|
presence_penalty: float = Field(0.0, ge = 0.0, le = 2.0, description = "Presence penalty")
|
|
image_base64: Optional[str] = Field(
|
|
None, description = "Base64 encoded image for vision models"
|
|
)
|
|
|
|
|
|
class LoadResponse(BaseModel):
|
|
"""Response after loading a model"""
|
|
|
|
status: str = Field(..., description = "Load status")
|
|
model: str = Field(..., description = "Model identifier")
|
|
display_name: str = Field(..., description = "Display name of the model")
|
|
is_vision: bool = Field(False, description = "Whether model is a vision model")
|
|
is_lora: bool = Field(False, description = "Whether model is a LoRA adapter")
|
|
is_gguf: bool = Field(
|
|
False, description = "Whether model is a GGUF model (llama.cpp)"
|
|
)
|
|
is_audio: bool = Field(False, description = "Whether model is a TTS audio model")
|
|
audio_type: Optional[str] = Field(
|
|
None, description = "Audio codec type: snac, csm, bicodec, dac"
|
|
)
|
|
has_audio_input: bool = Field(
|
|
False, description = "Whether model accepts audio input (ASR)"
|
|
)
|
|
inference: dict = Field(
|
|
..., description = "Inference parameters (temperature, top_p, top_k, min_p)"
|
|
)
|
|
requires_trust_remote_code: bool = Field(
|
|
False,
|
|
description = "Whether the model defaults require trust_remote_code to be enabled for loading.",
|
|
)
|
|
context_length: Optional[int] = Field(
|
|
None, description = "Model's native context length (from GGUF metadata)"
|
|
)
|
|
max_context_length: Optional[int] = Field(
|
|
None, description = "Maximum context length currently available on this hardware"
|
|
)
|
|
native_context_length: Optional[int] = Field(
|
|
None,
|
|
description = "Model's native context length from GGUF metadata (not capped by VRAM)",
|
|
)
|
|
supports_reasoning: bool = Field(
|
|
False,
|
|
description = "Whether model supports thinking/reasoning mode (enable_thinking or reasoning_effort)",
|
|
)
|
|
reasoning_style: Literal["enable_thinking", "reasoning_effort"] = Field(
|
|
"enable_thinking",
|
|
description = "Reasoning control style: 'enable_thinking' (boolean) or 'reasoning_effort' (low|medium|high)",
|
|
)
|
|
reasoning_always_on: bool = Field(
|
|
False,
|
|
description = "Whether reasoning is always on (hardcoded <think> tags, not toggleable)",
|
|
)
|
|
supports_preserve_thinking: bool = Field(
|
|
False,
|
|
description = "Whether the template understands the optional preserve_thinking kwarg (Qwen3.6-style)",
|
|
)
|
|
supports_tools: bool = Field(
|
|
False,
|
|
description = "Whether model supports tool calling (web search, etc.)",
|
|
)
|
|
cache_type_kv: Optional[str] = Field(
|
|
None,
|
|
description = "KV cache data type for K and V (e.g. 'f16', 'bf16', 'q8_0')",
|
|
)
|
|
chat_template: Optional[str] = Field(
|
|
None,
|
|
description = "Jinja2 chat template string (from GGUF metadata or tokenizer)",
|
|
)
|
|
speculative_type: Optional[str] = Field(
|
|
None,
|
|
description = "Active speculative decoding mode (e.g. 'ngram-simple', 'ngram-mod'), or None if disabled",
|
|
)
|
|
|
|
|
|
class UnloadResponse(BaseModel):
|
|
"""Response after unloading a model"""
|
|
|
|
status: str = Field(..., description = "Unload status")
|
|
model: str = Field(..., description = "Model identifier that was unloaded")
|
|
|
|
|
|
class LoadProgressResponse(BaseModel):
|
|
"""Progress of the active GGUF load, sampled on demand.
|
|
|
|
Used by the UI to show a real progress bar during the
|
|
post-download warmup window (mmap + CUDA upload), rather than a
|
|
generic "Starting model..." spinner that freezes for minutes on
|
|
large MoE models.
|
|
"""
|
|
|
|
phase: Optional[str] = Field(
|
|
None,
|
|
description = (
|
|
"Load phase: 'mmap' (weights paging into RAM via mmap), "
|
|
"'ready' (llama-server reported healthy), or null when no "
|
|
"load is in flight."
|
|
),
|
|
)
|
|
bytes_loaded: int = Field(
|
|
0,
|
|
description = (
|
|
"Bytes of the model already resident in the llama-server "
|
|
"process (VmRSS on Linux)."
|
|
),
|
|
)
|
|
bytes_total: int = Field(
|
|
0,
|
|
description = "Total bytes across all GGUF shards for the active model.",
|
|
)
|
|
fraction: float = Field(
|
|
0.0, description = "bytes_loaded / bytes_total, clamped to 0..1."
|
|
)
|
|
|
|
|
|
class InferenceStatusResponse(BaseModel):
|
|
"""Current inference backend status"""
|
|
|
|
active_model: Optional[str] = Field(
|
|
None, description = "Currently active model identifier"
|
|
)
|
|
is_vision: bool = Field(
|
|
False, description = "Whether the active model is a vision model"
|
|
)
|
|
is_gguf: bool = Field(
|
|
False, description = "Whether the active model is a GGUF model (llama.cpp)"
|
|
)
|
|
gguf_variant: Optional[str] = Field(
|
|
None, description = "GGUF quantization variant (e.g. Q4_K_M)"
|
|
)
|
|
is_audio: bool = Field(
|
|
False, description = "Whether the active model is a TTS audio model"
|
|
)
|
|
audio_type: Optional[str] = Field(
|
|
None, description = "Audio codec type: snac, csm, bicodec, dac"
|
|
)
|
|
has_audio_input: bool = Field(
|
|
False, description = "Whether model accepts audio input (ASR)"
|
|
)
|
|
loading: List[str] = Field(
|
|
default_factory = list, description = "Models currently being loaded"
|
|
)
|
|
loaded: List[str] = Field(
|
|
default_factory = list, description = "Models currently loaded"
|
|
)
|
|
inference: Optional[Dict[str, Any]] = Field(
|
|
None, description = "Recommended inference parameters for the active model"
|
|
)
|
|
requires_trust_remote_code: bool = Field(
|
|
False,
|
|
description = "Whether the active model requires trust_remote_code to be enabled for loading.",
|
|
)
|
|
supports_reasoning: bool = Field(
|
|
False, description = "Whether the active model supports reasoning/thinking mode"
|
|
)
|
|
reasoning_style: Literal["enable_thinking", "reasoning_effort"] = Field(
|
|
"enable_thinking",
|
|
description = "Reasoning control style: 'enable_thinking' (boolean) or 'reasoning_effort' (low|medium|high)",
|
|
)
|
|
reasoning_always_on: bool = Field(
|
|
False, description = "Whether reasoning is always on (not toggleable)"
|
|
)
|
|
supports_preserve_thinking: bool = Field(
|
|
False,
|
|
description = "Whether the active model's template understands the optional preserve_thinking kwarg",
|
|
)
|
|
supports_tools: bool = Field(
|
|
False, description = "Whether the active model supports tool calling"
|
|
)
|
|
context_length: Optional[int] = Field(
|
|
None, description = "Context length of the active model"
|
|
)
|
|
max_context_length: Optional[int] = Field(
|
|
None,
|
|
description = "Maximum context length currently available for the active model",
|
|
)
|
|
native_context_length: Optional[int] = Field(
|
|
None,
|
|
description = "Model's native context length from GGUF metadata (not capped by VRAM)",
|
|
)
|
|
cache_type_kv: Optional[str] = Field(
|
|
None,
|
|
description = "KV cache quantization dtype (e.g. 'q8_0'), or None for default",
|
|
)
|
|
chat_template: Optional[str] = Field(
|
|
None, description = "Model's default chat template (Jinja2 source), if any"
|
|
)
|
|
chat_template_override: Optional[str] = Field(
|
|
None,
|
|
description = "Active chat template override applied at load time, or None if model is using its default",
|
|
)
|
|
speculative_type: Optional[str] = Field(
|
|
None,
|
|
description = "Active speculative decoding mode (e.g. 'ngram-simple', 'ngram-mod'), or None if disabled",
|
|
)
|
|
|
|
|
|
# =====================================================================
|
|
# OpenAI-Compatible Chat Completions Models
|
|
# =====================================================================
|
|
|
|
|
|
# ── Multimodal content parts (OpenAI vision format) ──────────────
|
|
|
|
|
|
class TextContentPart(BaseModel):
|
|
"""Text content part in a multimodal message."""
|
|
|
|
type: Literal["text"]
|
|
text: str
|
|
|
|
|
|
class ImageUrl(BaseModel):
|
|
"""Image URL object — supports data URIs and remote URLs."""
|
|
|
|
url: str = Field(..., description = "data:image/png;base64,... or https://...")
|
|
detail: Optional[Literal["auto", "low", "high"]] = "auto"
|
|
|
|
|
|
class ImageContentPart(BaseModel):
|
|
"""Image content part in a multimodal message."""
|
|
|
|
type: Literal["image_url"]
|
|
image_url: ImageUrl
|
|
|
|
|
|
def _content_part_discriminator(v):
|
|
if isinstance(v, dict):
|
|
return v.get("type")
|
|
return getattr(v, "type", None)
|
|
|
|
|
|
ContentPart = Annotated[
|
|
Union[
|
|
Annotated[TextContentPart, Tag("text")],
|
|
Annotated[ImageContentPart, Tag("image_url")],
|
|
],
|
|
Discriminator(_content_part_discriminator),
|
|
]
|
|
"""Union type for multimodal content parts, discriminated by the 'type' field."""
|
|
|
|
|
|
# ── Messages ─────────────────────────────────────────────────────
|
|
|
|
|
|
class ChatMessage(BaseModel):
|
|
"""
|
|
A single message in the conversation.
|
|
|
|
``content`` may be a plain string (text-only) or a list of
|
|
content parts for multimodal messages (OpenAI vision format).
|
|
Assistant messages that only contain tool calls may set ``content``
|
|
to ``None`` with ``tool_calls`` populated. ``role="tool"`` messages
|
|
carry the result of a client-executed tool call and require
|
|
``tool_call_id`` per the OpenAI spec.
|
|
"""
|
|
|
|
role: Literal["system", "user", "assistant", "tool"] = Field(
|
|
..., description = "Message role"
|
|
)
|
|
content: Optional[Union[str, list[ContentPart]]] = Field(
|
|
None, description = "Message content (string or multimodal parts)"
|
|
)
|
|
tool_call_id: Optional[str] = Field(
|
|
None,
|
|
description = "OpenAI tool-result messages: id of the tool call this result belongs to.",
|
|
)
|
|
tool_calls: Optional[list[dict]] = Field(
|
|
None,
|
|
description = "OpenAI assistant messages: structured tool calls the model decided to make.",
|
|
)
|
|
name: Optional[str] = Field(
|
|
None,
|
|
description = "OpenAI tool-result messages: name of the tool whose result this is.",
|
|
)
|
|
|
|
@model_validator(mode = "after")
|
|
def _validate_role_shape(self) -> "ChatMessage":
|
|
if self.tool_calls is not None and self.role != "assistant":
|
|
raise ValueError('"tool_calls" is only valid on role="assistant" messages.')
|
|
if self.tool_call_id is not None and self.role != "tool":
|
|
raise ValueError('"tool_call_id" is only valid on role="tool" messages.')
|
|
if self.name is not None and self.role != "tool":
|
|
raise ValueError('"name" is only valid on role="tool" messages.')
|
|
|
|
if self.role == "tool":
|
|
if not self.tool_call_id:
|
|
# Frontend's second-round POST drops the streamed id;
|
|
# synthesise one so the request round-trips.
|
|
import secrets as _secrets
|
|
|
|
self.tool_call_id = f"call_{_secrets.token_hex(8)}"
|
|
if not self.content:
|
|
raise ValueError('role="tool" messages require non-empty "content".')
|
|
elif self.role == "assistant":
|
|
# Tolerate the post-Stop empty-assistant sentinel by
|
|
# collapsing content="" to None.
|
|
if (self.content == "" or self.content == []) and not self.tool_calls:
|
|
self.content = None
|
|
else: # "user" | "system"
|
|
if self.content is None or self.content == []:
|
|
raise ValueError(f'role="{self.role}" messages require "content".')
|
|
return self
|
|
|
|
|
|
class ChatCompletionRequest(BaseModel):
|
|
"""
|
|
OpenAI-compatible chat completion request.
|
|
|
|
Extensions (non-OpenAI fields) are marked with 'x-unsloth'.
|
|
"""
|
|
|
|
# Accept unknown fields defensively so future OpenAI fields (seed,
|
|
# response_format, logprobs, frequency_penalty, etc.) don't get
|
|
# silently dropped by Pydantic before route code runs. Mirrors
|
|
# AnthropicMessagesRequest and ResponsesRequest.
|
|
model_config = {"extra": "allow"}
|
|
|
|
model: str = Field(
|
|
"default",
|
|
description = "Model identifier (informational; the active model is used)",
|
|
)
|
|
messages: list[ChatMessage] = Field(..., description = "Conversation messages")
|
|
stream: bool = Field(
|
|
False,
|
|
description = (
|
|
"Whether to stream the response via SSE. Default matches OpenAI's "
|
|
"spec (`false`); opt into streaming by sending `stream: true`."
|
|
),
|
|
)
|
|
temperature: float = Field(0.6, ge = 0.0, le = 2.0)
|
|
top_p: float = Field(0.95, ge = 0.0, le = 1.0)
|
|
max_tokens: Optional[int] = Field(
|
|
None, ge = 1, description = "Maximum tokens to generate (None = until EOS)"
|
|
)
|
|
presence_penalty: float = Field(0.0, ge = 0.0, le = 2.0, description = "Presence penalty")
|
|
stop: Optional[Union[str, list[str]]] = Field(
|
|
None,
|
|
description = "OpenAI stop sequences: a single string or list of strings at which generation halts.",
|
|
)
|
|
tools: Optional[list[dict]] = Field(
|
|
None,
|
|
description = (
|
|
"OpenAI function-tool definitions. When provided without `enable_tools=true`, "
|
|
"Studio forwards the tools to the backend so the model returns structured "
|
|
"tool_calls for the client to execute (standard OpenAI function calling)."
|
|
),
|
|
)
|
|
tool_choice: Optional[Union[str, dict]] = Field(
|
|
None,
|
|
description = (
|
|
"OpenAI tool choice: 'auto' | 'required' | 'none' | "
|
|
"{'type': 'function', 'function': {'name': ...}}"
|
|
),
|
|
)
|
|
|
|
# ── Unsloth extensions (ignored by standard OpenAI clients) ──
|
|
top_k: int = Field(20, ge = -1, le = 100, description = "[x-unsloth] Top-k sampling")
|
|
min_p: float = Field(
|
|
0.01, ge = 0.0, le = 1.0, description = "[x-unsloth] Min-p sampling threshold"
|
|
)
|
|
repetition_penalty: float = Field(
|
|
1.0, ge = 1.0, le = 2.0, description = "[x-unsloth] Repetition penalty"
|
|
)
|
|
image_base64: Optional[str] = Field(
|
|
None, description = "[x-unsloth] Base64-encoded image for vision models"
|
|
)
|
|
audio_base64: Optional[str] = Field(
|
|
None, description = "[x-unsloth] Base64-encoded WAV for audio-input models (ASR)"
|
|
)
|
|
use_adapter: Optional[Union[bool, str]] = Field(
|
|
None,
|
|
description = (
|
|
"[x-unsloth] Adapter control for compare mode. "
|
|
"null = no change (default), "
|
|
"false = disable adapters (base model), "
|
|
"true = enable the current adapter, "
|
|
"string = enable a specific adapter by name."
|
|
),
|
|
)
|
|
enable_thinking: Optional[bool] = Field(
|
|
None,
|
|
description = "[x-unsloth] Enable/disable thinking/reasoning mode for supported models",
|
|
)
|
|
reasoning_effort: Optional[
|
|
Literal["none", "minimal", "low", "medium", "high", "max", "xhigh"]
|
|
] = Field(
|
|
None,
|
|
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,
|
|
description = "[x-unsloth] When true, keep historical <think> blocks from past assistant turns in the prompt (Qwen3.6 templates). Independent of enable_thinking / reasoning_effort.",
|
|
)
|
|
enable_tools: Optional[bool] = Field(
|
|
None,
|
|
description = "[x-unsloth] Enable tool calling for supported models",
|
|
)
|
|
enabled_tools: Optional[list[str]] = Field(
|
|
None,
|
|
description = "[x-unsloth] List of enabled tool names (e.g. ['web_search', 'python', 'terminal']). If None, all tools are enabled.",
|
|
)
|
|
auto_heal_tool_calls: Optional[bool] = Field(
|
|
True,
|
|
description = "[x-unsloth] Auto-detect and fix malformed tool calls from model output.",
|
|
)
|
|
max_tool_calls_per_message: Optional[int] = Field(
|
|
25,
|
|
ge = 0,
|
|
description = "[x-unsloth] Maximum number of tool call iterations per message (0 = disabled, 9999 = unlimited).",
|
|
)
|
|
tool_call_timeout: Optional[int] = Field(
|
|
300,
|
|
ge = 1,
|
|
description = "[x-unsloth] Timeout in seconds for each tool call execution (9999 = no limit).",
|
|
)
|
|
session_id: Optional[str] = Field(
|
|
None,
|
|
description = "[x-unsloth] Session/thread ID for scoping tool execution sandbox.",
|
|
)
|
|
cancel_id: Optional[str] = Field(
|
|
None,
|
|
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 ────────────────────────────────────
|
|
|
|
|
|
class ChoiceDelta(BaseModel):
|
|
"""Delta content for a streaming chunk."""
|
|
|
|
role: Optional[str] = None
|
|
content: Optional[str] = None
|
|
|
|
|
|
class ChunkChoice(BaseModel):
|
|
"""A single choice in a streaming chunk."""
|
|
|
|
index: int = 0
|
|
delta: ChoiceDelta
|
|
finish_reason: Optional[Literal["stop", "length"]] = None
|
|
|
|
|
|
class ChatCompletionChunk(BaseModel):
|
|
"""A single SSE chunk in OpenAI streaming format."""
|
|
|
|
id: str = Field(default_factory = lambda: f"chatcmpl-{uuid.uuid4().hex[:12]}")
|
|
object: Literal["chat.completion.chunk"] = "chat.completion.chunk"
|
|
created: int = Field(default_factory = lambda: int(time.time()))
|
|
model: str = "default"
|
|
choices: list[ChunkChoice]
|
|
usage: Optional[CompletionUsage] = None
|
|
timings: Optional[dict] = None
|
|
|
|
|
|
# ── Non-streaming response ───────────────────────────────────────
|
|
|
|
|
|
class CompletionMessage(BaseModel):
|
|
"""The assistant's complete response message."""
|
|
|
|
role: Literal["assistant"] = "assistant"
|
|
content: str
|
|
|
|
|
|
class CompletionChoice(BaseModel):
|
|
"""A single choice in a non-streaming response."""
|
|
|
|
index: int = 0
|
|
message: CompletionMessage
|
|
finish_reason: Literal["stop", "length"] = "stop"
|
|
|
|
|
|
class CompletionUsage(BaseModel):
|
|
"""Token usage statistics (approximate)."""
|
|
|
|
prompt_tokens: int = 0
|
|
completion_tokens: int = 0
|
|
total_tokens: int = 0
|
|
|
|
|
|
class ChatCompletion(BaseModel):
|
|
"""Non-streaming chat completion response."""
|
|
|
|
id: str = Field(default_factory = lambda: f"chatcmpl-{uuid.uuid4().hex[:12]}")
|
|
object: Literal["chat.completion"] = "chat.completion"
|
|
created: int = Field(default_factory = lambda: int(time.time()))
|
|
model: str = "default"
|
|
choices: list[CompletionChoice]
|
|
usage: CompletionUsage = Field(default_factory = CompletionUsage)
|
|
|
|
|
|
# =====================================================================
|
|
# OpenAI Responses API Models (/v1/responses)
|
|
# =====================================================================
|
|
|
|
|
|
# ── Request models ──────────────────────────────────────────────
|
|
|
|
|
|
class ResponsesInputTextPart(BaseModel):
|
|
"""Text content part in a Responses API message (type=input_text)."""
|
|
|
|
type: Literal["input_text"]
|
|
text: str
|
|
|
|
|
|
class ResponsesInputImagePart(BaseModel):
|
|
"""Image content part in a Responses API message (type=input_image)."""
|
|
|
|
type: Literal["input_image"]
|
|
image_url: str = Field(..., description = "data:image/png;base64,... or https://...")
|
|
detail: Optional[Literal["auto", "low", "high"]] = "auto"
|
|
|
|
|
|
class ResponsesOutputTextPart(BaseModel):
|
|
"""Assistant ``output_text`` content part replayed on subsequent turns.
|
|
|
|
When a client (OpenAI Codex CLI, OpenAI Python SDK agents) loops on a
|
|
stateless Responses endpoint, prior assistant messages are round-tripped
|
|
as ``{"role":"assistant","content":[{"type":"output_text","text":...,
|
|
"annotations":[],"logprobs":[]}]}``. We preserve the text and ignore
|
|
the annotations/logprobs metadata when flattening into Chat Completions.
|
|
"""
|
|
|
|
type: Literal["output_text"]
|
|
text: str
|
|
annotations: Optional[list] = None
|
|
logprobs: Optional[list] = None
|
|
|
|
model_config = {"extra": "allow"}
|
|
|
|
|
|
class ResponsesUnknownContentPart(BaseModel):
|
|
"""Catch-all for content-part types we don't model explicitly.
|
|
|
|
Keeps validation green when a client sends newer part types (e.g.
|
|
``input_audio``, ``input_file``) we haven't mapped; these are silently
|
|
skipped during normalisation rather than rejected with a 422.
|
|
"""
|
|
|
|
type: str
|
|
|
|
model_config = {"extra": "allow"}
|
|
|
|
|
|
ResponsesContentPart = Union[
|
|
ResponsesInputTextPart,
|
|
ResponsesInputImagePart,
|
|
ResponsesOutputTextPart,
|
|
ResponsesUnknownContentPart,
|
|
]
|
|
|
|
|
|
class ResponsesInputMessage(BaseModel):
|
|
"""A single message in the Responses API input array."""
|
|
|
|
type: Optional[Literal["message"]] = None
|
|
role: Literal["system", "user", "assistant", "developer"]
|
|
content: Union[str, list[ResponsesContentPart]]
|
|
|
|
# Codex (gpt-5.3-codex+) attaches a `phase` field ("commentary" |
|
|
# "final_answer") to assistant messages and requires clients to preserve
|
|
# it on subsequent turns. We accept and round-trip it; llama-server does
|
|
# not care about it.
|
|
model_config = {"extra": "allow"}
|
|
|
|
|
|
class ResponsesFunctionCallInputItem(BaseModel):
|
|
"""A prior assistant function_call being replayed in a multi-turn Responses input.
|
|
|
|
The Responses API represents tool calls as top-level input items (not
|
|
nested inside assistant messages), correlated across turns by ``call_id``.
|
|
"""
|
|
|
|
type: Literal["function_call"]
|
|
id: Optional[str] = Field(
|
|
None, description = "Item id assigned by the server (e.g. fc_...)"
|
|
)
|
|
call_id: str = Field(
|
|
...,
|
|
description = "Correlation id matching a function_call_output on the next turn.",
|
|
)
|
|
name: str
|
|
arguments: str = Field(
|
|
..., description = "JSON string of the arguments the model produced."
|
|
)
|
|
status: Optional[Literal["in_progress", "completed", "incomplete"]] = None
|
|
|
|
|
|
class ResponsesFunctionCallOutputInputItem(BaseModel):
|
|
"""A tool result supplied by the client for a prior function_call.
|
|
|
|
Replaces Chat Completions' ``role="tool"`` message. Correlated to the
|
|
originating call by ``call_id``.
|
|
"""
|
|
|
|
type: Literal["function_call_output"]
|
|
id: Optional[str] = None
|
|
call_id: str
|
|
output: Union[str, list] = Field(
|
|
..., description = "String or content-array result of the tool call."
|
|
)
|
|
status: Optional[Literal["in_progress", "completed", "incomplete"]] = None
|
|
|
|
|
|
class ResponsesUnknownInputItem(BaseModel):
|
|
"""Catch-all for Responses input item types we don't model explicitly.
|
|
|
|
Covers ``reasoning`` items (replayed from prior o-series / gpt-5 turns)
|
|
and any future item types the client may send. These items are dropped
|
|
during normalisation — llama-server-backed GGUFs cannot consume them —
|
|
but keeping them in the request-model union stops unrelated turns from
|
|
failing validation with a 422.
|
|
"""
|
|
|
|
type: str
|
|
|
|
model_config = {"extra": "allow"}
|
|
|
|
|
|
def _responses_input_item_discriminator(v: Any) -> str:
|
|
"""Route a Responses input item to the correct tagged variant.
|
|
|
|
Pydantic's default smart-union matching fails when one variant in the
|
|
union is tagged with a strict ``Literal`` (``function_call`` /
|
|
``function_call_output``) and the incoming dict uses a different
|
|
``type`` — the other variants' validation errors are hidden and the
|
|
outer ``Union[str, list[...]]`` reports a misleading "Input should be a
|
|
valid string" error. An explicit discriminator makes the routing
|
|
deterministic and lets us fall through to the catch-all.
|
|
"""
|
|
if isinstance(v, dict):
|
|
t = v.get("type")
|
|
r = v.get("role")
|
|
else:
|
|
t = getattr(v, "type", None)
|
|
r = getattr(v, "role", None)
|
|
if t == "function_call":
|
|
return "function_call"
|
|
if t == "function_call_output":
|
|
return "function_call_output"
|
|
if r is not None or t == "message":
|
|
return "message"
|
|
return "unknown"
|
|
|
|
|
|
ResponsesInputItem = Annotated[
|
|
Union[
|
|
Annotated[ResponsesInputMessage, Tag("message")],
|
|
Annotated[ResponsesFunctionCallInputItem, Tag("function_call")],
|
|
Annotated[ResponsesFunctionCallOutputInputItem, Tag("function_call_output")],
|
|
Annotated[ResponsesUnknownInputItem, Tag("unknown")],
|
|
],
|
|
Discriminator(_responses_input_item_discriminator),
|
|
]
|
|
|
|
|
|
class ResponsesFunctionTool(BaseModel):
|
|
"""Flat function-tool definition used by the Responses API request.
|
|
|
|
Unlike Chat Completions (which nests ``{"name": ..., "parameters": ...}``
|
|
inside a ``"function"`` key), the Responses API uses a flat shape with
|
|
``type``, ``name``, ``description``, ``parameters``, and ``strict`` at the
|
|
top level of each tool entry.
|
|
"""
|
|
|
|
type: Literal["function"]
|
|
name: str
|
|
description: Optional[str] = None
|
|
parameters: Optional[dict] = None
|
|
strict: Optional[bool] = None
|
|
|
|
|
|
class ResponsesRequest(BaseModel):
|
|
"""OpenAI Responses API request."""
|
|
|
|
model: str = Field("default", description = "Model identifier")
|
|
input: Union[str, list[ResponsesInputItem]] = Field(
|
|
default = [],
|
|
description = "Input text or list of messages / function_call / function_call_output items",
|
|
)
|
|
instructions: Optional[str] = Field(
|
|
None, description = "System / developer instructions"
|
|
)
|
|
temperature: Optional[float] = Field(None, ge = 0.0, le = 2.0)
|
|
top_p: Optional[float] = Field(None, ge = 0.0, le = 1.0)
|
|
max_output_tokens: Optional[int] = Field(None, ge = 1)
|
|
stream: bool = Field(False, description = "Whether to stream the response via SSE")
|
|
|
|
# OpenAI function-calling fields — forwarded to llama-server via the
|
|
# Chat Completions pass-through (see routes/inference.py). Typed as a
|
|
# plain list so built-in tool shapes (``web_search``, ``file_search``,
|
|
# ``mcp``, ...) round-trip without validation errors — the translator
|
|
# picks out only ``type=="function"`` entries for forwarding.
|
|
tools: Optional[list[dict]] = Field(
|
|
None,
|
|
description = (
|
|
"Responses-shape function tool definitions. Entries with "
|
|
'`type="function"` are translated to the Chat Completions nested '
|
|
"shape before being forwarded to llama-server; other tool types "
|
|
"(built-in web_search, file_search, mcp, ...) are accepted for SDK "
|
|
"compatibility but ignored on the llama-server passthrough."
|
|
),
|
|
)
|
|
tool_choice: Optional[Any] = Field(
|
|
None,
|
|
description = (
|
|
"'auto' | 'required' | 'none' | {'type': 'function', 'name': ...} — "
|
|
"the Responses-shape forcing object is translated to the Chat "
|
|
"Completions nested shape internally."
|
|
),
|
|
)
|
|
parallel_tool_calls: Optional[bool] = None
|
|
|
|
previous_response_id: Optional[str] = None
|
|
store: Optional[bool] = None
|
|
metadata: Optional[dict] = None
|
|
truncation: Optional[Any] = None
|
|
user: Optional[str] = None
|
|
text: Optional[Any] = None
|
|
reasoning: Optional[Any] = None
|
|
|
|
model_config = {"extra": "allow"}
|
|
|
|
|
|
# ── Response models ─────────────────────────────────────────────
|
|
|
|
|
|
class ResponsesOutputTextContent(BaseModel):
|
|
"""A text content block inside an output message."""
|
|
|
|
type: Literal["output_text"] = "output_text"
|
|
text: str
|
|
annotations: list = Field(default_factory = list)
|
|
|
|
|
|
class ResponsesOutputMessage(BaseModel):
|
|
"""An output message in the Responses API response."""
|
|
|
|
type: Literal["message"] = "message"
|
|
id: str = Field(default_factory = lambda: f"msg_{uuid.uuid4().hex[:12]}")
|
|
status: Literal["completed", "in_progress"] = "completed"
|
|
role: Literal["assistant"] = "assistant"
|
|
content: list[ResponsesOutputTextContent] = Field(default_factory = list)
|
|
|
|
|
|
class ResponsesOutputFunctionCall(BaseModel):
|
|
"""A function-call output item in the Responses API response.
|
|
|
|
Unlike Chat Completions (which nests tool calls inside the assistant
|
|
message), the Responses API emits each tool call as its own top-level
|
|
``output`` item so clients can correlate results via ``call_id`` on the
|
|
next turn.
|
|
"""
|
|
|
|
type: Literal["function_call"] = "function_call"
|
|
id: str = Field(default_factory = lambda: f"fc_{uuid.uuid4().hex[:12]}")
|
|
call_id: str
|
|
name: str
|
|
arguments: str = Field(
|
|
..., description = "JSON string of the arguments the model produced."
|
|
)
|
|
status: Literal["completed", "in_progress", "incomplete"] = "completed"
|
|
|
|
|
|
ResponsesOutputItem = Union[ResponsesOutputMessage, ResponsesOutputFunctionCall]
|
|
|
|
|
|
class ResponsesUsage(BaseModel):
|
|
"""Token usage for a Responses API response (input_tokens, not prompt_tokens)."""
|
|
|
|
input_tokens: int = 0
|
|
output_tokens: int = 0
|
|
total_tokens: int = 0
|
|
|
|
|
|
class ResponsesResponse(BaseModel):
|
|
"""Top-level Responses API response object."""
|
|
|
|
id: str = Field(default_factory = lambda: f"resp_{uuid.uuid4().hex[:12]}")
|
|
object: Literal["response"] = "response"
|
|
created_at: int = Field(default_factory = lambda: int(time.time()))
|
|
status: Literal["completed", "in_progress", "failed"] = "completed"
|
|
model: str = "default"
|
|
output: list[ResponsesOutputItem] = Field(default_factory = list)
|
|
usage: ResponsesUsage = Field(default_factory = ResponsesUsage)
|
|
error: Optional[Any] = None
|
|
incomplete_details: Optional[Any] = None
|
|
instructions: Optional[str] = None
|
|
metadata: dict = Field(default_factory = dict)
|
|
temperature: Optional[float] = None
|
|
top_p: Optional[float] = None
|
|
max_output_tokens: Optional[int] = None
|
|
previous_response_id: Optional[str] = None
|
|
text: Optional[Any] = None
|
|
tool_choice: Optional[Any] = None
|
|
tools: list = Field(default_factory = list)
|
|
truncation: Optional[Any] = None
|
|
|
|
|
|
# =====================================================================
|
|
# Anthropic Messages API Models (/v1/messages)
|
|
# =====================================================================
|
|
|
|
|
|
# ── Request models ─────────────────────────────────────────────
|
|
|
|
|
|
class AnthropicTextBlock(BaseModel):
|
|
type: Literal["text"]
|
|
text: str
|
|
|
|
|
|
class AnthropicImageSource(BaseModel):
|
|
type: Literal["base64", "url"]
|
|
media_type: Optional[str] = None
|
|
data: Optional[str] = None
|
|
url: Optional[str] = None
|
|
|
|
|
|
class AnthropicImageBlock(BaseModel):
|
|
type: Literal["image"]
|
|
source: AnthropicImageSource
|
|
|
|
|
|
class AnthropicToolUseBlock(BaseModel):
|
|
type: Literal["tool_use"]
|
|
id: str
|
|
name: str
|
|
input: dict
|
|
|
|
|
|
class AnthropicToolResultBlock(BaseModel):
|
|
type: Literal["tool_result"]
|
|
tool_use_id: str
|
|
content: Union[str, list] = ""
|
|
|
|
|
|
AnthropicContentBlock = Union[
|
|
AnthropicTextBlock,
|
|
AnthropicImageBlock,
|
|
AnthropicToolUseBlock,
|
|
AnthropicToolResultBlock,
|
|
]
|
|
|
|
|
|
class AnthropicMessage(BaseModel):
|
|
role: Literal["user", "assistant"]
|
|
content: Union[str, list[AnthropicContentBlock]]
|
|
|
|
|
|
class AnthropicTool(BaseModel):
|
|
name: str
|
|
description: Optional[str] = None
|
|
input_schema: dict
|
|
|
|
|
|
class AnthropicMessagesRequest(BaseModel):
|
|
model: str = "default"
|
|
max_tokens: Optional[int] = None
|
|
messages: list[AnthropicMessage]
|
|
system: Optional[Union[str, list]] = None
|
|
tools: Optional[list[AnthropicTool]] = None
|
|
tool_choice: Optional[Any] = None
|
|
stream: bool = False
|
|
temperature: Optional[float] = None
|
|
top_p: Optional[float] = None
|
|
top_k: Optional[int] = None
|
|
stop_sequences: Optional[list[str]] = None
|
|
metadata: Optional[dict] = None
|
|
# [x-unsloth] extensions — mirror the OpenAI endpoint convenience fields
|
|
min_p: Optional[float] = Field(
|
|
None, ge = 0.0, le = 1.0, description = "[x-unsloth] Min-p sampling threshold"
|
|
)
|
|
repetition_penalty: Optional[float] = Field(
|
|
None, ge = 1.0, le = 2.0, description = "[x-unsloth] Repetition penalty"
|
|
)
|
|
presence_penalty: Optional[float] = Field(
|
|
None, ge = 0.0, le = 2.0, description = "[x-unsloth] Presence penalty"
|
|
)
|
|
enable_tools: Optional[bool] = None
|
|
enabled_tools: Optional[list[str]] = None
|
|
session_id: Optional[str] = None
|
|
cancel_id: Optional[str] = None
|
|
model_config = {"extra": "allow"}
|
|
|
|
|
|
# ── Response models ────────────────────────────────────────────
|
|
|
|
|
|
class AnthropicUsage(BaseModel):
|
|
input_tokens: int = 0
|
|
output_tokens: int = 0
|
|
|
|
|
|
class AnthropicResponseTextBlock(BaseModel):
|
|
type: Literal["text"] = "text"
|
|
text: str
|
|
|
|
|
|
class AnthropicResponseToolUseBlock(BaseModel):
|
|
type: Literal["tool_use"] = "tool_use"
|
|
id: str
|
|
name: str
|
|
input: dict
|
|
|
|
|
|
AnthropicResponseBlock = Union[
|
|
AnthropicResponseTextBlock, AnthropicResponseToolUseBlock
|
|
]
|
|
|
|
|
|
class AnthropicMessagesResponse(BaseModel):
|
|
id: str = Field(default_factory = lambda: f"msg_{uuid.uuid4().hex[:24]}")
|
|
type: Literal["message"] = "message"
|
|
role: Literal["assistant"] = "assistant"
|
|
content: list[AnthropicResponseBlock] = Field(default_factory = list)
|
|
model: str = "default"
|
|
stop_reason: Optional[str] = None
|
|
stop_sequence: Optional[str] = None
|
|
usage: AnthropicUsage = Field(default_factory = AnthropicUsage)
|