Commit graph

8 commits

Author SHA1 Message Date
Lee Jackson
7307fde839
Studio: Add custom provider option to Connections (#6112)
* feat: add custom connection

* Fix custom provider handling for PR #6112

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix custom provider connection test for PR #6112

---------

Co-authored-by: wasimysaid <wasimysdev@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-12 13:09:35 +02:00
Daniel Han
187144d4e7
Reduce and tighten code comments and docstrings repo-wide (#6095)
Trim and tighten code comments and docstrings across the repository. Comment-only: every changed file verified code-identical to main via AST/token comparison.
2026-06-08 23:09:51 -07:00
Daniel Han
8292e699e4
Studio: make code comments and docstrings more succinct (#6029)
Trim and tighten code comments and docstrings across studio/ Python. Comment-only: every changed file verified code-identical to main via AST/token comparison.
2026-06-08 23:07:28 -07:00
Daniel Han
3ce187da02
Formatting: ruff line-length 100, kwarg-spacing passes, drop blank after short local imports (#6079)
Raise ruff line-length to 100 and extend the local pre-commit format pipeline (def-signature magic-comma normalization, short multi-line assert collapse, kwarg '=' spacing, blank-line-after-short-import removal, adjacent string-literal / f-string+plain merge, redundant-pass pruning). Every transform re-checks the file AST and is dropped if it would differ; the whole-repo reformat is verified AST-identical per file and idempotent.
2026-06-08 04:24:13 -07:00
Daniel Han
ab48465135
Studio: add Gemini provider with web_search, code_execution, prompt caching, and Nano Banana image generation (#5720)
* Studio: add Gemini provider with web_search, code_execution, prompt caching, and Nano Banana image generation

Wires Google's native Gemini API into Studio's external-provider stack
so users can pick gemini-2.5-pro / gemini-2.5-flash / gemini-2.5-flash-image
(Nano Banana) alongside the existing OpenAI / Anthropic / OpenRouter
providers. Gemini does not speak OpenAI Chat Completions on its primary
endpoint; the new `_stream_gemini` async generator translates between
the two shapes the same way `_stream_anthropic` handles the Messages API.

Backend:
- New `_stream_gemini` translator in external_provider.py. Converts
  OpenAI messages -> Gemini `contents` + `systemInstruction`; maps
  generationConfig (temperature / topP / topK / maxOutputTokens);
  forwards `tools: [{googleSearch: {}}]` for web_search and
  `{codeExecution: {}}` for code_execution; passes `cachedContent`
  through for prompt caching; sets `responseModalities=[TEXT, IMAGE]`
  for Nano Banana image generation.
- Translates streamed `GenerateContentResponse` SSE frames back into
  OpenAI chat.completion.chunk frames (text deltas, function_call ->
  tool_calls deltas, inlineData -> image_b64 tool_end envelope, usage
  chunk before [DONE]).
- Registry entry switched to native base URL
  `https://generativelanguage.googleapis.com/v1beta` with
  `openai_compatible: False` and the `x-goog-api-key` auth header.
  Model lineup curated to current 2.5 / 2.0 family + Nano Banana.

Frontend:
- Provider-capability matrix: Gemini supports temperature, top_p, top_k,
  presence_penalty (matches generationConfig); min_p / repetition_penalty
  hidden because the API does not accept them.
- `providerSupportsBuiltinWebSearch` / `providerSupportsBuiltinCodeExecution`
  / `providerSupportsBuiltinImageGeneration` extended for Gemini.
- Prompt caching toggle now also lit on Gemini.

Tests:
- 21 new tests in `test_gemini_provider.py` using httpx.MockTransport.
  Cover request body shape conversion, URL/header wiring, web_search
  forwarded as googleSearch, function-call translation both directions,
  prompt caching passthrough, image generation emitting image_b64,
  grounded-search citations -> tool_end, finish_reason mapping, and
  vision data URL -> inlineData translation.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: forward presence_penalty to Gemini and recover function name from tool_call_id

Two follow-up fixes for the Gemini provider:

  * Thread presence_penalty into _stream_gemini and set
    generationConfig.presencePenalty when non-zero. The OpenAI-side
    capability matrix already exposes the slider for Gemini, so the
    value was being collected and silently dropped on the way out.

  * When an OpenAI role=tool message omits 'name' and only carries
    'tool_call_id', recover the function name from the matching
    functionCall on the prior assistant turn. Gemini 400s on an empty
    functionResponse name.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: surface Gemini code execution parts as code_execution tool events

The Gemini stream parser only handled text/functionCall/inlineData
parts, so when the user toggled the Code pill on a Gemini model the
sandbox output (executableCode + codeExecutionResult parts) was
dropped on the floor while adjacent text reached the UI. Reviewers
flagged this as the headline feature being silently broken.

Translate both parts into the existing code_execution tool envelope
that CodeExecutionToolUI already consumes for OpenAI / Anthropic:

  * executableCode  -> tool_start with kind=code_execution and the
    source code under arguments.code. We mint a tool_call_id and
    stash it so the matching result block can pair to it.
  * codeExecutionResult -> tool_end on that id with the stdout under
    result. Non-OK outcomes (OUTCOME_FAILED / OUTCOME_DEADLINE_EXCEEDED)
    are prefixed onto the text so the failure is visible.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: native Gemini model catalog, function-call ids, and honest cache claim

Three follow-ups to the Gemini provider PR after the codex pass:

  * list_models() now translates Gemini's native /v1beta/models
    payload ({models[{name, baseModelId, displayName,
    supportedGenerationMethods}]}) into the OpenAI-compatible shape
    Studio expects. Without this the picker stayed empty for Gemini
    and fell back to hardcoded defaults. Embedding-only models are
    filtered out.

  * Forward the OpenAI tool_call id into Gemini's functionCall.id
    and mirror it onto functionResponse.id. Two parallel calls to
    the same function name can now be paired unambiguously on the
    follow-up turn.

  * Drop Gemini from the prompt-caching capability set. The wire
    flow requires a separate cachedContents POST first and the
    boolean Studio emits today is a no-op; the toggle should not
    advertise a feature it cannot apply. Leaves a pointer to the
    docs for the eventual two-step orchestration.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: distinct tool_calls index per emitted Gemini function call

Codex flagged that the Gemini stream parser hardcoded
tool_calls[0].index to 0 on every emitted functionCall. OpenAI
reassemblers key tool_calls by index when joining deltas, so two
parallel function calls in one assistant turn collapsed onto a
single slot and the second call's arguments overwrote the first.

Track the running count via len(emitted_function_call_ids) - 1
and emit it as the per-call index. The dedupe guard above (skip
when fc_id already in the set) means the index is monotonic and
stable for the lifetime of the stream. Regression test asserts
[0, 1] across two parallel calls in one candidate parts list.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: surface Gemini 3.5/3.1/3 + Nano Banana 2/Pro and plumb thinking budget

`gemini-2.0-flash` / `gemini-2.0-flash-exp` were retired by Google in 2026
(`/v1beta/models/gemini-2.0-flash:streamGenerateContent` returns HTTP 404
"no longer available to new users"), and the picker had nothing past the
2.x family. Verified against the live ListModels catalog: drop the retired
ids from `default_models` + allowlist and surface the chat-capable
3.5 / 3.1 / 3 families plus the Nano Banana image trio.

Also plumb `enable_thinking` / `reasoning_effort` into Gemini's
`generationConfig.thinkingConfig`. Without this, Gemini 3.5 Flash,
gemini-pro-latest, and the 3.x previews silently spend the caller's
`max_tokens` budget on hidden "thoughts" before emitting any visible
answer -- the chat shows a truncated stub like "The capital of" and
streams stop. Mapping:
  - enable_thinking=False / reasoning_effort=none -> thinkingBudget=0
    (Flash tier; Pro tier coerces to a small positive budget because
    the API 400s on 0 with "This model only works in thinking mode")
  - minimal/low/medium/high -> 512/2048/8192/24576 budget tokens
  - max/xhigh -> -1 (dynamic)
  - default (neither knob set) -> thinkingConfig omitted, model decides

Frontend `getExternalReasoningCapabilities` now surfaces a
`reasoning_effort` picker for every Gemini chat id (Pro tier hides the
"none" option; image-tier ids stay knob-less). Adds 6 unit tests
covering Flash/Pro effort mapping, the off-toggle coercion on Pro,
default omission, and the nano-banana-pro-preview alias routing
through the image modalities path. 28 -> 34 tests in
`test_gemini_provider.py`, all green; full backend suite still passes
(1459/1460; the unrelated test_help_output flake is pre-existing and
not in any file this PR touches).

Live verification against generativelanguage.googleapis.com on
2026-05-24 with `_stream_gemini` directly:
  text   gemini-3.5-flash           single PASS  multi PASS
  text   gemini-3.1-pro-preview     single PASS  multi PASS
  text   gemini-3.1-flash-lite      single PASS  multi PASS
  text   gemini-3-pro-preview       single PASS  multi PASS
  text   gemini-3-flash-preview     single PASS  multi PASS
  text   gemini-2.5-pro             single PASS  multi PASS
  text   gemini-2.5-flash           single PASS  multi PASS
  text   gemini-2.5-flash-lite      single PASS  multi PASS
  text   gemini-flash-latest        single PASS  multi PASS
  text   gemini-flash-lite-latest   single PASS  multi PASS
  text   gemini-pro-latest          single PASS  multi PASS
  image  gemini-2.5-flash-image     PASS (1082 KB png returned)
  image  gemini-3.1-flash-image-preview  PASS (Nano Banana 2)
  image  gemini-3-pro-image-preview      PASS (Nano Banana Pro)
  tool   web_search                 PASS
  tool   code_execution             PASS
  -> 16/16 e2e through the actual ExternalProviderClient code path.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: tighten Gemini provider after review (PR #5720)

Fixes a batch of bugs surfaced by a second-pass review on top of the
3.5/3.1/3 + Nano Banana 2/Pro additions in c6724dbd.

Backend (external_provider.py):
- Constructor normalises legacy /v1beta/openai base URLs to /v1beta so
  Gemini providers saved before the native switch keep working without
  a manual re-config.
- Skip thinkingConfig, googleSearch, and codeExecution on image-tier
  models (-image / nano-banana). The image responseModalities path is
  mutually exclusive with text-tool wiring and stale UI state would
  otherwise 400 the turn.
- _PRO_THINKING_PREFIXES now includes gemini-3.5-pro and uses anchored
  prefix matching (exact id or "<prefix>-...") so the image-tier
  gemini-3-pro-image-preview cannot accidentally match the pro guard.
- Gemini 3 functionCall thoughtSignature is round-tripped through the
  tool_calls envelope via extra_content.google.thought_signature on
  emit, and replayed as a sibling of functionCall on the next request.
- finishReason swaps STOP -> tool_calls when any functionCall was
  emitted on the same turn so OAI clients trigger tool execution
  (matches the OpenAI Chat Completions contract).
- usageMetadata.thoughtsTokenCount is rolled into output_tokens and
  surfaced on output_tokens_details.reasoning_tokens so total_tokens
  reflects the full billable spend instead of dropping the hidden
  reasoning slice.

Registry (providers.py):
- Drop gemini-3-pro-preview from default_models. Google shut it down
  on 2026-03-09 and auto-redirects to gemini-3.1-pro-preview; we
  surface the canonical id only.
- Add model_id_deny_exact = ("gemini-3-pro-preview",) so the live
  ListModels fetch does not re-surface the redirect alias.

Route schema (models/inference.py):
- enable_prompt_caching widened to Optional[Union[bool, str]] so the
  /v1/chat/completions caller can pass a Gemini cachedContent resource
  name (e.g. cachedContents/abc123). Without this widening _stream_gemini
  s string cachedContent passthrough was unreachable from the public
  route (bool_parsing 422). stream_chat_completion signature mirrors.

Frontend (provider-capabilities.ts, chat-page.tsx, chat-adapter.ts):
- providerSupportsBuiltinImageGeneration now also recognises
  nano-banana ids (nano-banana-pro-preview was hidden from the image
  pill before).
- providerSupportsBuiltinWebSearch takes the model id so Gemini image
  models hide the Search pill (mirrors the backend skip).
- providerSupportsBuiltinCodeExecution uses the same isGeminiImageModel
  guard for nano-banana ids.
- GEMINI_THINKING_PRO_PREFIXES gains gemini-3.5-pro; gemini-3-pro
  tightened to gemini-3-pro-preview to avoid the image-id overlap.
- Updated 3 callers of providerSupportsBuiltinWebSearch to thread the
  selected model id through.

Tests (test_gemini_provider.py): 34 -> 42, all green
- test_image_models_skip_thinking_config
- test_image_models_drop_text_only_tools
- test_gemini_35_pro_recognized_as_pro_thinking
- test_legacy_openai_base_url_normalized
- test_finish_reason_swaps_to_tool_calls_when_function_call_emitted
- test_thought_signature_round_trips_into_gemini_function_call
- test_thought_signature_emitted_in_tool_call_delta
- test_usage_chunk_includes_thoughts_tokens

Verification:
- Backend pytest 1518/1519 passing (one unrelated Qwen3.5 flash-attn
  test fails on main as well; nothing in this PR touches that path).
- Frontend npx tsc -b clean.
- Live e2e 16/16 against generativelanguage.googleapis.com through the
  patched _stream_gemini code path (all 11 chat models single + multi
  turn, all 3 image models returned image bytes, web_search and
  code_execution tools both emit the expected envelope).
- Live /api/providers/models against the patched backend surfaces 16
  ids (gemini-3-pro-preview correctly filtered via deny_exact).

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: address second-pass review findings on Gemini (PR #5720)

Round-2 reviewer.py flagged a phantom web_search card on image
turns (12/12 reviewers), route-layer stripping of tool_calls /
tool_call_id / name, an over-narrow image-mode tool guard, and
silent safety blocks. This patch fixes all four.

Backend (external_provider.py):
- web_search_active is now derived from the outbound tools_array
  (whether googleSearch was actually forwarded), not the raw
  enabled_tools intent. Image-mode turns dropped the tool above so
  the inbound stream no longer emits a phantom "search complete"
  tool_start / tool_end on those turns.
- text_tools_allowed now uses is_image_model (covers both `-image`
  / `nano-banana` picker models AND text models that requested
  `image_generation` via enabled_tools). Verified against the live
  Gemini API which rejects both googleSearch and codeExecution
  alongside responseModalities=["TEXT","IMAGE"] with explicit 400s
  ("Search as tool is not enabled for this model", "Code execution
  is not enabled for this model").
- promptFeedback.blockReason is surfaced as a 400 content-filter
  error chunk instead of returning an empty successful assistant
  response. The streaming loop closes the response before exiting.

Route (routes/inference.py):
- _build_external_messages now propagates tool_calls (assistant),
  tool_call_id, and name (tool result) through every code path
  (string content, multimodal content, non-vision fallback). Without
  this Gemini 3 function-call round trips lost their thoughtSignature
  + tool_call_id at the route boundary, and functionResponse.name
  arrived empty on the second turn.
- Assistant messages with content=None and tool_calls populated are
  preserved as a synthetic empty-string content turn so the
  Gemini translator can rebuild the functionCall part.

Tests (test_gemini_provider.py): 42 -> 45, all green
- test_image_models_suppress_phantom_web_search_card
- test_image_generation_tool_drops_text_tools
- test_prompt_feedback_block_reason_surfaces_as_error

Verification:
- Backend pytest 1736 / 1736 (the two pre-existing unrelated fails
  on main, test_help_output and Qwen3.5 flash-attn pin, are skipped).
- Frontend npx tsc -b clean.
- Live e2e 16/16 against generativelanguage.googleapis.com:
  11 chat models single + multi turn, 3 image models returning
  image bytes, web_search and code_execution both PASS.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: fix third-pass Gemini findings (PR #5720)

Round 3 review follow-ups:

Backend (studio/backend/core/inference/external_provider.py):
- Close response AND aiter_lines iterator in a finally so normal,
  prompt-block, and cancellation exits all clean up (eliminates the
  RuntimeWarning about aclose never being awaited).
- Pair the synthetic web_search tool_start with a tool_end on the
  promptFeedback.blockReason path so the UI does not leave a stuck
  "searching..." spinner after the error toast.
- Preserve native id and thoughtSignature on executableCode and
  codeExecutionResult tool events under google.native_part, and pair
  the tool_end on the code-exec id so multi-turn code-execution
  replays do not lose Gemini-required history.
- Carry part-level thoughtSignature on text deltas via
  delta.extra_content.google.thought_signature and on inline image
  tool_end via google.thought_signature so Gemini 3 image editing
  and tool turns round-trip the signature on the next request.
- Guess remote image_url MIME from the URL path so PNG / WebP / GIF
  inputs are not silently relabeled as JPEG.
- Roll usageMetadata.toolUsePromptTokenCount into translated input
  tokens and surface thoughtsTokenCount as
  completion_tokens_details.reasoning_tokens in _build_usage_chunk.
- Only normalize the Google-hosted /v1beta/openai legacy base URL;
  custom proxies whose paths happen to end in /openai are left
  untouched.
- Forward ChatCompletionRequest.tools and tool_choice through
  stream_chat_completion into _stream_gemini, translating to
  tools[].functionDeclarations and toolConfig.functionCallingConfig.

Frontend:
- chat-adapter: when Gemini image-generation is enabled for the turn,
  also disable Search and Code so the request, builder, and active
  pills agree with what the backend actually sends (the backend
  already strips text tools when image_generation is in enabled_tools).
- chat-adapter: consume OpenAI-shape delta.tool_calls chunks so
  Gemini function-call deltas without text surface as tool-call parts.
- shared-composer: disable Search and Code pills while Gemini image
  mode is active so the UI matches the request.

Tests (studio/backend/tests/test_gemini_provider.py): adds coverage
for proxy base-url gating, remote image MIME inference,
toolUsePromptTokenCount, reasoning_tokens propagation, prompt-block
web_search tool_end pairing, native code-exec id/thoughtSignature
metadata, inline image thoughtSignature, text-chunk extra_content,
OpenAI tools/tool_choice translation, and image-model tool drop.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: Gemini 3 thinkingLevel + image-model Search grounding (PR #5720)

Gemini 3.x migrated to a string `thinkingConfig.thinkingLevel`
(MINIMAL/LOW/MEDIUM/HIGH) and rejects `thinkingBudget`+`thinkingLevel`
in the same request. Gemini 3 also cannot turn thinking fully off, so
the lowest position is "minimal" (Flash) or "low" (Pro rejects
"minimal").

- external_provider._stream_gemini: split thinking translation by
  family. Gemini 3.x (3 / 3.1 / 3.5 + gemini-pro-latest /
  gemini-flash-latest / gemini-flash-lite-latest) emits
  thinkingConfig.thinkingLevel; effort none/off coerces to "low" on
  Pro and "minimal" on Flash. Gemini 2.5 stays on thinkingBudget.
- external_provider._stream_gemini: allow `tools: [{googleSearch: {}}]`
  on the Gemini 3 image family (gemini-3-pro-image-preview,
  gemini-3.1-flash-image-preview, nano-banana-pro). Google's docs
  document Search grounding on these. codeExecution stays blocked
  on image mode (still mutually exclusive with responseModalities).
- provider-capabilities.ts: mirror the Gemini 3 effort ladders in
  resolveGeminiReasoningCapabilities (Pro: low/medium/high; Flash:
  minimal/low/medium/high; 2.5 Flash keeps the off-position).
- provider-capabilities.ts: providerSupportsBuiltinWebSearch now
  returns true on the documented Gemini 3 image models so the pill
  is reachable; older image ids (gemini-2.5-flash-image) still hide.

Tests: splits the existing thinkingBudget cases by family (Gemini 3
checks thinkingLevel; Gemini 2.5 keeps thinkingBudget), adds positive
googleSearch coverage for Gemini 3 image models and negative
googleSearch coverage for legacy image models.

References:
- https://ai.google.dev/gemini-api/docs/thinking
- https://ai.google.dev/gemini-api/docs/gemini-3
- https://ai.google.dev/gemini-api/docs/models/gemini-3-pro-image-preview

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: attach Gemini code_execution inline images to the code card (PR #5720)

When a text Gemini turn wires codeExecution and the sandbox produces a
matplotlib plot, the inline image part ships right after the
codeExecutionResult. Previously this surfaced as a separate empty
image_generation card. Track the most recent code_execution
tool_call_id + result text and, when an inline image follows with
code_execution active, emit a second tool_end on the same id that
appends the image as a data: URI under the `__IMAGES__:` marker the
chat-adapter already understands.

Image-picker turns (`-image` / `nano-banana`) keep the standalone
image_generation envelope so Nano Banana outputs render the same way.

Tests: covers the merged code-execution card emission with no
standalone image_generation event when code_execution is the active
tool.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: fix fourth-pass Gemini findings (PR #5720)

Round 4 review follow-ups:

Backend:
- `_is_openai_compatible` + `_auth_headers` detect Gemini connections
  pointed at a custom OpenAI-compatible proxy (non-Google host whose
  path ends in `/openai`) and route them through the OpenAI-compat
  surface with `Authorization: Bearer ...` instead of the native
  `_stream_gemini` translator + `x-goog-api-key`. Google-hosted Gemini
  keeps the native dispatch path it migrated to in this PR.
- `_stream_gemini` thinkingLevel handling for Gemini 3 Pro now coerces
  both "minimal" and "medium" effort to "low" / "high" respectively
  (Pro tier only accepts low/high per
  https://ai.google.dev/gemini-api/docs/thinking).
- `providers.py` `default_models` restores the advertised
  `gemini-3.5-pro` and the rolling `gemini-pro-latest` /
  `gemini-flash-latest` / `gemini-flash-lite-latest` aliases that the
  allowlist already admits.

Frontend:
- chat-adapter: lean on `providerSupportsBuiltinWebSearch` (which
  already encodes the Gemini 3 image-model Search allowance) instead
  of blanket-disabling Search whenever Gemini image mode is active.
  Code execution stays blocked because Gemini image mode rejects it.
- shared-composer: mirror the same gate -- only the Code pill is
  unconditionally disabled in Gemini image mode; the Search pill is
  driven by `supportsBuiltinWebSearch`.
- provider-capabilities: Gemini 3 Pro reasoning levels now expose only
  "low" and "high" (no Medium pill) to match the API.

Tests: covers the Gemini 3 Pro medium / minimal coercion, the custom
proxy OAI-compat dispatch + Authorization Bearer auth, and the
native-vs-proxy detection. Also closes the mocked httpx.AsyncClient
inside the test event loop so the Python 3.13 `aclose was never
awaited` warning no longer fires.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: fix fifth-pass Gemini findings (PR #5720)

Round 5 review follow-ups:

Backend:
- `_is_openai_compatible` + `_auth_headers` now treat ANY non-Google
  Gemini base URL as OpenAI-compat (LiteLLM / custom OAI gateways /
  OpenAI-compat vLLM routers), not just paths ending in `/openai`.
  Pre-existing saved Gemini proxies on `/v1` keep working.
- Gemini 3 thinkingLevel coercion narrowed to the documented
  inconsistencies: only "minimal" is coerced to "low" on Pro tier.
  "medium" passes through (Gemini 3.1 Pro accepts it per
  https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/3-1-pro).
- `_stream_gemini` only flips `responseModalities=[TEXT,IMAGE]` when
  the selected model is image-capable. A stale
  `enabled_tools=["image_generation"]` on a text model is silently
  dropped instead of producing an invalid Gemini request.
- `_stream_gemini` validates the model id against
  `[A-Za-z0-9._-]+` before URL interpolation so a model like
  `../cachedContents/x` cannot redirect the request to an unintended
  endpoint with the configured API key attached.
- Empty-text Gemini parts that still carry `thoughtSignature` emit a
  content-free delta with `extra_content.google.thought_signature` so
  Gemini 3 turns that end with a signature-only fragment do not lose
  the replay state.
- ConnectError / ReadTimeout / generic HTTPError paths in
  `_stream_gemini` now close the synthetic web_search tool_start
  with a matching tool_end before the error chunk so the UI does not
  leave a stuck "searching..." card on transport failure.
- `providers.py` default_models drop the non-existent
  `gemini-3.5-pro` (Google launched only `gemini-3.5-flash` at
  I/O 2026; Pro tier remains `gemini-3.1-pro-preview`).
- `routes/inference.py` only forwards `payload.top_k` when the caller
  explicitly set it on the request (Pydantic `model_fields_set`).
  Omitted top_k stays omitted, restoring the pre-PR behavior where
  Gemini uses its server default.
- `ChatCompletionRequest.enable_prompt_caching` adds a `mode="before"`
  validator that coerces the canonical string literals "true"/"false"
  back to bool so historical opt-out callers keep working after the
  field widened to `Union[bool, str]` for Gemini cache resource names.

Frontend:
- `providerSupportsBuiltinWebSearch` / Code / Image now accept the
  saved connection `baseUrl` and return false for custom OAI-compat
  Gemini proxies. Backend skips `_stream_gemini` for those bases, so
  native tool envelopes never reach them; hiding the pills keeps the
  request, builder, and UI consistent.
- `provider-capabilities.ts` Gemini 3 Pro effort ladder restores
  `["low", "medium", "high"]` to match Google's documented levels.
- Call sites in `chat-page.tsx` and `chat-adapter.ts` pass through
  `provider.baseUrl` so the proxy gate fires.

Tests: covers Gemini 3 Pro medium pass-through, custom proxy dispatch
on `/v1` and `/openai` bases, path-traversal model id rejection,
top_k omission when not explicit, text-model image_generation drop,
empty-text + thoughtSignature surfacing, and
enable_prompt_caching string coercion.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: fix sixth-pass Gemini findings (PR #5720)

Round 6 review follow-ups:

Frontend:
- chat-adapter `delta.tool_calls` accumulates fragments by `id` /
  `index` instead of pushing a new tool-call card per chunk. The
  standard OpenAI Chat Completions stream contract sends `id`/`name`
  on the first chunk and partial `function.arguments` on subsequent
  chunks; our previous handler parsed each fragment as a standalone
  tool call. Local llama.cpp and OAI-compat providers that stream
  fragments now reassemble into a single function-call part.
- chat-adapter also preserves `extra_content` on streamed tool-call
  deltas so Gemini 3 `thoughtSignature` survives to the next turn.
- provider-capabilities Gemini 3 Pro restores "medium" in the
  reasoning-effort ladder (Google's official Gemini API thinking
  doc lists low/medium/high for Gemini 3.1 Pro; my earlier round 4
  coercion was wrong).
- provider-capabilities orders `gemini-2.5-flash-lite` ahead of the
  broader `gemini-2.5-flash` prefix so Flash-Lite falls into the
  "no native thinking knob" branch as documented.

* Studio: round-trip Gemini tool_calls and tool results (PR #5720)

Recurring round 3-6 P1: the chat-adapter renders Gemini function-call
parts and code-execution events but `toOpenAIMessage` only serialized
text + image content, so the next turn lost the assistant
`tool_calls[]` (including Gemini 3's required
`extra_content.google.thought_signature`) and the matching
`role="tool"` result. Gemini 3 multi-turn function calling and code
execution failed validation on the second turn.

Frontend:
- types/api.ts widens OpenAIChatMessage to permit `role="tool"`,
  `tool_calls`, `tool_call_id`, `name`, and `content: null`. Adds
  OpenAIToolCallPart with `extra_content` for the Gemini round-trip.
- chat-adapter: new `toOpenAIMessages` expands an assistant turn with
  tool-call parts into [assistant w/ tool_calls + extra_content,
  role=tool result, ...]. tool result content is JSON-serialized so
  the backend translator can rebuild Gemini's `functionResponse`
  shape.
- chat-adapter outbound history now uses `flatMap(toOpenAIMessages)`
  so each assistant tool-call round-trips through the standard OAI
  shape the backend's `_stream_gemini` already understands.

* Studio: replay Gemini code_execution and image native parts on history (PR #5720)

Multi-turn Gemini history previously lost the native executableCode,
codeExecutionResult, and inlineData parts because the outbound
translator regenerated a generic functionCall for every assistant
tool_call. Stow the native dict on tool_end (frontend) and replay it
verbatim with thoughtSignature (backend) so follow-up turns preserve
the prior execution and image generation state. Skip role="tool"
fan-out for server-side builtin tools so Gemini does not 400 on a
functionResponse with no matching user-declared function.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: complete Gemini built-in tool replay round-trip (PR #5720)

Round 7 follow-up to the multi-turn native-part work. Three asymmetric
storage/consume gaps remained between the backend translator and the
chat adapter, so realistic Gemini follow-up turns degraded to generic
functionCalls instead of native history.

- Frontend collectAssistantToolCalls now drops web_search outright,
  drops code_execution / image_generation when the native part is
  missing, and promotes args.google to extra_content.google so the
  backend native_part replay branch actually fires.
- Backend image_generation tool_end now emits google.native_part
  with the inlineData (mimeType + base64) and thoughtSignature so the
  follow-up image-edit turn can replay the prior image as a native
  Gemini model part.
- Backend code-execution plot tool_end now stows google.native_part
  with the inlineData so the merged code-exec card can round-trip
  executableCode + codeExecutionResult + inlineData on the same id.
- Added regression tests for image-gen native-part replay and the
  code-exec plot native_part stow.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: round 8 Gemini follow-ups (PR #5720)

- Text-part thoughtSignature: stow on the assistant message during
  streaming and replay onto the last text part on the next turn so
  Gemini 3 strict function-calling does not reject history.
- Function declarations: recursively strip Gemini-unsupported OpenAPI
  keys (additionalProperties, $schema, $defs, strict, etc.) so OpenAI
  strict tools stop 400ing as INVALID_ARGUMENT on Gemini.
- OpenAI-compat fallback: forward tools/tool_choice so custom Gemini
  proxies (LiteLLM, gateways) keep function-calling.
- enable_prompt_caching: cover the Pydantic v1 legacy off/on/f/n/t/y
  string set so explicit opt-outs stay opt-out (Gemini was sending
  cachedContent: "off" otherwise).
- Frontend collectAssistantToolCalls / collectToolResultMessages: use
  google.native_part + result presence to disambiguate provider
  builtins from same-named user-declared functions.
- Added regression tests for text-signature replay and schema
  sanitization.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: round 9 Gemini follow-ups (PR #5720)

Two round-9 convergent finds across the 12 reviewers:

- Server-side web_search was leaking onto the next turn as a fake
  user functionCall/functionResponse. The previous heuristic (skip
  builtin only when no native_part AND no result) let it through
  because the synthetic tool card has a non-empty result string.
  Always skip web_search by name on both serializers, accept that a
  user-declared function literally named "web_search" must use a
  different name.
- Assistant `extra_content` was dropped by ChatMessage validation
  before _stream_gemini could replay text-part thought signatures.
  Add the field to ChatMessage and forward it through
  _build_external_messages so the multi-turn signature path actually
  carries data.

Includes a regression test for the ChatMessage round-trip.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: round 10 Gemini follow-ups (PR #5720)

Three convergent round-10 reviewer findings closed:

- Tag synthetic provider-side builtins with `args._server_tool=True`
  via a central helper that runs in every `_emit_tool_event` /
  `_emit_synthetic_tool_event` path. The frontend filter now skips
  on that marker instead of on the public tool name, so local
  llama.cpp `web_search` and OpenAI function tools literally named
  `web_search` / `code_execution` / `image_generation` round-trip
  cleanly while Gemini grounding / hosted code-exec / hosted image
  cards stay skipped.
- Gate Gemini image-mode (responseModalities=[TEXT,IMAGE]) on the
  Images pill (enabled_tools containing `image_generation`).
  Selecting an image-capable model with the pill off no longer forces
  image output the UI says is disabled.
- Frontend missing-key guard now exempts custom Gemini OAI-compat
  proxies (LiteLLM, gateways) the same way the backend already
  does, so a saved Gemini connection on `http://localhost:4000/v1`
  with no API key stops being blocked.

Existing tests updated to pass `enabled_tools=["image_generation"]`
on image-mode capture paths.

* Studio: round 11 Gemini follow-ups (PR #5720)

Four round-11 findings closed:

- Kimi _stream_kimi_web_search's local _synthetic_chunk helper now
  runs through _stamp_server_tool_marker so Kimi search history is
  not replayed as a fake user functionCall on the next turn (was an
  asymmetric miss after the round-10 tagging work).
- OpenAI Responses path (/v1/responses for gpt-5.x) forwards
  caller-supplied tools / tool_choice, translating the Chat
  Completions function-tool shape into the Responses native shape.
  Without this, standard OpenAI tools silently dropped on
  Responses-routed traffic.
- Decoupled the Gemini image-tier model-id guards (text-tool /
  thinking strip) from the Images pill flip
  (responseModalities=[TEXT,IMAGE]). gemini-2.5-flash-image with
  Search/Code on and the Images pill OFF no longer forwards
  googleSearch + thinkingConfig (Gemini 400s on those for legacy
  image ids).
- Gemini-only extra_content is now forwarded by
  _build_external_messages only when provider_type=="gemini" so
  Google's thought_signature does not leak into OpenAI / Mistral /
  Kimi / OpenRouter request bodies as an unknown field.

Added a regression test for the image-tier strict-guard split and
extended the extra_content test to cover the non-Gemini suppression.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: round 12 Gemini follow-ups (PR #5720)

Three round-12 convergent findings closed:

- extra_content leak to custom Gemini OAI-compat proxies (8/12
  reviewers). _build_external_messages now gates extra_content on
  the native generativelanguage.googleapis.com host, not just
  provider_type=="gemini", so LiteLLM / custom gateways routed
  through /chat/completions do not get an unknown top-level field.
- OpenAI Responses function-tool round-trip (5/12 reviewers). I
  added user `tools` forwarding in round 11 but did not parse the
  matching response.output_item.done items of type=function_call.
  The parser now translates them into Chat Completions
  delta.tool_calls and the terminal chunk reports
  finish_reason="tool_calls" when the model invoked a user
  function.
- Image-tier model with Images pill OFF (2/12). Google's image
  models default to text+image when responseModalities is omitted,
  so the previous fix silently still billed image output. Force
  responseModalities=["TEXT"] when the Images pill is off and the
  selected model is image-capable.

Updated the two pre-existing tests that pinned the synthetic-tool
arguments shape to include the new `_server_tool: True` marker, and
added a regression test for the Responses function-call output
translation.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: round 13 Gemini/Responses follow-ups (PR #5720)

Three round-13 convergent findings closed:

- OpenAI Responses function_call indices: my round-12 translator
  hardcoded every emitted tool_calls[*].index to 0, so parallel
  function calls collapsed for index-keyed clients. Track and
  increment function_call_index per emit (mirrors the Gemini
  branch's distinct-index pattern). 10/12 reviewers flagged.
- _SERVER_SIDE_BUILTIN_TOOL_NAMES now includes web_fetch so
  Anthropic-hosted web_fetch cards carry the _server_tool marker
  and the frontend history serializer doesn't replay them as fake
  user functions. 4 reviewers flagged.
- OpenAI Responses follow-up tool results now serialize as
  Responses-shape function_call / function_call_output items keyed
  by call_id, instead of Chat Completions role="tool" content.
  Skips assistant tool_calls tagged with _server_tool so hosted
  builtins don't round-trip as user functions. 2 reviewers flagged.

Updated the Anthropic code_execution and web_fetch test argument
pins to include the new _server_tool marker, and added two
regression tests (distinct indices on parallel function_call,
function_call_output round-trip).

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: round 14 Gemini follow-ups (PR #5720)

Three round-14 findings closed:

- Remote `image_url` translation (5 reviewers convergent). Public
  HTTPS image URLs can't be sent as `fileData.fileUri` -- Gemini
  reserves that path for Files API URIs and YouTube. Fetch the
  bytes server-side and inline them as base64 `inlineData`,
  mirroring the pre-PR OpenAI-compat behaviour. YouTube URLs and
  generativelanguage.googleapis.com/v1beta/files/* stay as
  `fileData`.
- Nullable JSON Schema type arrays. OpenAI strict tools commonly
  use `"type": ["string", "null"]`; the Gemini sanitizer now
  flattens that to `"type": "string", "nullable": true` so strict
  function tools stop 400ing.
- Parallel functionResponses now ride on one user content block
  with multiple `functionResponse` parts, matching Google's
  parallel tool docs. Consecutive `role="tool"` messages merge
  into the previous user turn instead of splitting into separate
  Gemini user turns.

Three regression tests added (remote URL fetch + inline, Files
API / YouTube fileData preservation, schema nullable flattening,
parallel-tool grouping).

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: SSRF harden Gemini remote image fetch (PR #5720)

Round 15 convergent finding (12/12 reviewers). My round-14 fix to
download user-controlled image URLs for inlineData inlining was an
SSRF / data-exfiltration path: no scheme check, no private-host
guard, no size cap, no Content-Type validation, redirects could
bounce to internal services, and the full URL was logged.

Replace the inline fetch with `_safe_fetch_image_for_gemini`:

- Require https:// (reject http, file, data, ftp, etc).
- Resolve the hostname via socket.getaddrinfo and reject if ANY
  resolved address is private / loopback / link-local / multicast /
  reserved / unspecified (covers 127.0.0.0/8, 10/8, 172.16/12,
  192.168/16, ::1, 169.254/16 metadata, RFC 6890).
- Block IP-literal URLs that resolve into those same ranges.
- Cap response body at 10 MB (Content-Length pre-check + streamed
  byte counter).
- Require Content-Type to start with `image/`.
- Disable redirect following so a 302 to a private host can't slip
  past the address check.
- Use a short 15s timeout and a tiny connection pool dedicated to
  these fetches.
- Log only the host name + error class -- no full URL, no signed
  querystring leak.

If the guard rejects, the image part is silently dropped (instead
of forwarding raw bytes or a fileData fallback). Files API URIs
and YouTube URLs still ride as `fileData.fileUri` unchanged.

Tests: replaced the live-fetch test with a `_safe_fetch_image_for_gemini`
monkeypatch, added four new SSRF-guard tests (non-https rejected,
loopback / private IP literals rejected, hostnames that resolve to
private IPs rejected).

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: round 16 Gemini follow-ups (PR #5720)

- IP-pinned image fetch (`_safe_fetch_image_for_gemini`): reuse the
  validated-once-then-pin pattern from `tools._fetch_page_text` via
  `asyncio.to_thread`, so DNS rebinding between validation and the
  HTTP connect cannot redirect us at a private/metadata address.
  Catch malformed-bracketed IPv6 urlparse errors. Follow up to 4
  redirect hops with per-hop SSRF re-validation.
- Replace contains-substring detection of Gemini Files API + YouTube
  URLs with parsed scheme/host/path checks, so attacker URLs like
  `https://evil.example/path/youtube.com/x.png` no longer skip the
  safe-fetch path and serialize as `fileData.fileUri`.
- `_build_external_messages`: strip per-tool-call `extra_content`
  for non-native-Gemini providers; the Gemini-only
  `thought_signature` payload was leaking through `tool_calls[]`
  into /chat/completions on OpenAI, Anthropic, and custom Gemini
  OAI-compat gateways.
- `_server_tool` marker now gated on the function name being one of
  the canonical builtin names (`web_search`, `web_fetch`,
  `code_execution`, `image_generation`) AND the marker being set,
  so a user function whose schema happens to define an
  `_server_tool` field is no longer dropped. Frontend filter mirrors
  the same gate, plus a backward-compat fallback for pre-PR
  persisted server-tool cards (no marker) routed via name +
  native_part / web-tool heuristic.
- Gemini schema sanitizer collapses `anyOf: [{X}, {"type":"null"}]`
  to `{X, "nullable": true}` so Optional[X] tool args from
  OpenAI/Pydantic schemas no longer 400 the Gemini request.
- Frontend tool-result serializer emits `{"result":""}` for empty
  string outputs so the ChatMessage validator does not reject
  `role="tool"` with empty content.
- Coerce `medium` thinkingLevel to `high` for legacy
  `gemini-3-pro*` / `gemini-3-pro-preview*` (only low/high
  documented; shut down 2026-03-09); 3.1+ Pro still passes through.
- Hide Gemini native thinking ladder on custom OAI-compat Gemini
  gateways by routing `getExternalReasoningCapabilities` through
  `isGeminiCustomOpenAICompatBase(baseUrl)`; thread baseUrl through
  all four call sites.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: round 17 Gemini follow-ups (PR #5720)

- Frontend `collectAssistantToolCalls` and `collectToolResultMessages`
  no longer drop unmarked `web_search` / `web_fetch` cards by name
  alone: a user-defined function with one of those names must
  round-trip. Pre-PR persisted `code_execution` / `image_generation`
  cards still get filtered via a shape heuristic (kind/command/code/
  prompt fields) instead of bare name.
- `_build_external_messages._filter_tool_calls` now drops marked
  server-side builtin `tool_calls` entirely for non-native-Gemini
  providers, not just their `extra_content`. An assistant turn whose
  only payload was a marked builtin is dropped completely so the
  receiving provider does not see an orphan tool_call.
- `_stream_anthropic` translates OpenAI top-level `tool_calls` into
  Anthropic native `{type:"tool_use", id, name, input}` content
  blocks, and translates `role="tool"` follow-ups into `role:"user"`
  messages carrying a `tool_result` block. Anthropic's native
  Messages API rejects the OpenAI shapes.
- `_safe_fetch_image_for_gemini_sync` factors URL validation through
  `_safe_parse_https`, so malformed `port` access (e.g.
  `https://host:bad/x.png`) and malformed redirect targets (e.g. a
  302 to `https://[bad/x.png`) drop the image instead of raising mid-
  request.
- `tool_choice="none"` now disables hosted builtins (Gemini
  googleSearch / codeExecution and OpenAI Responses web_search /
  shell / image_generation), not just user function declarations.
- Schema sanitizer handles multi-type `anyOf` with null
  (`Union[str, int, None]`): keep the slim non-null anyOf and add
  `nullable: true` so Gemini does not reject `{"type":"null"}`.
- Image fetch falls back to the caller-provided MIME (guessed from
  URL extension) when the server omits Content-Type instead of
  dropping the image as `non-image content-type=<none>`.
- Per-request aggregate caps on remote image inlining (8 images,
  20MB total) so a single chat request cannot force unbounded
  backend downloads.
- Frontend exposes the reasoning ladder for `gemini-2.5-flash-lite`
  (`none/minimal/low/medium/high/max`) so the UI can drive the
  thinkingBudget the backend already supports.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: round 18 Gemini follow-ups (PR #5720)

- `tool_choice="none"` now opts out of hosted builtin tools on every
  provider path, not just Gemini and OpenAI Responses. Anthropic
  web_search / web_fetch / code_execution, Kimi `$web_search` early
  return, and OpenRouter `plugins:[{id:"web"}]` are all gated on
  `tool_choice_disabled`. Passing `enabled_tools=[...]` with
  `tool_choice="none"` no longer triggers provider-side search /
  code execution for any provider.
- `_stream_anthropic` accepts `tool_choice` and threads it through;
  the dispatcher in `stream_chat_completion` forwards it.
- Frontend `isServerSideBuiltinToolPart` simplified to drop only on
  (marker) OR (canonical name + native_part). The previous shape
  heuristic on `args.kind`/`args.command`/`args.code`/`args.prompt`
  dropped real user-declared `code_execution` / `image_generation`
  functions. Pre-PR persisted hosted cards lacking the marker now
  leak to non-native providers on switch -- preferred to silently
  deleting legitimate function-call history.
- Backend `_is_marked_server_builtin_tool_call` and the OpenAI
  Responses translator's matching filter accept BOTH `_server_tool`
  marker AND `args.google.native_part` as durable provider-side
  signals so Gemini code_execution / image_generation cards are
  still dropped on a provider switch.
- Per-request remote image count cap now counts ATTEMPTS, not just
  successful inlines, so 100 failing/slow URLs cannot each consume
  the 15s fetch timeout. Data: URL images now share the same count
  and byte caps as fetched remote URLs.
- OpenAI Responses translator tracks skipped server-builtin
  `function_call` ids and drops their matching `role="tool"`
  follow-ups, preventing orphan `function_call_output` items in the
  outbound body.
- Gemini schema sanitizer preserves multi-type unions with null:
  `{"type":["string","integer","null"]}` becomes
  `anyOf:[{string},{integer}] + nullable:true` instead of being
  flattened to the first non-null type.
- Gemini model id validation moved to the top of `_stream_gemini`
  so an invalid model id rejects the request before any remote
  image fetch / message translation side effect.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: round 19 Gemini follow-ups (PR #5720)

- `_build_external_messages` now skips an empty assistant turn when
  `_filter_tool_calls` drops every synthetic builtin tool_call (was
  guarded only on the `content is None` branch; the string-content
  and list-content branches still forwarded
  `{"role":"assistant","content":""}` which several providers
  reject). Also tracks the dropped server-builtin tool_call ids and
  skips the matching `role="tool"` follow-ups so the receiving
  provider does not see an orphan tool_result.
- OpenRouter `web_search_active` (the synthetic tool_start /
  tool_end emitter) is now also gated on `tool_choice_disabled` so
  a request with `tool_choice="none"` does not surface a fake
  web_search card in the chat UI even though the plugin was
  correctly stripped from the outbound body.
- `_stream_anthropic` translates an OpenAI role="tool" with list
  content (`content=[{"type":"text","text":"..."}]`) into a native
  `tool_result` block on a user message; previously only the
  string-content shape was translated, so list-content tool results
  were forwarded as invalid `role:"tool"` messages.
- Gemini `data:` URL image_url parts now require an `image/*` MIME
  type; a `data:text/html;base64,...` is dropped instead of being
  forwarded as `inlineData.mimeType="text/html"` (Gemini rejects
  the malformed image part). Symmetric with the fetched-remote
  image fetch path that already rejects non-image Content-Type.
- YouTube `fileData.fileUri` now declares `video/mp4` as the
  mimeType instead of `image/jpeg` guessed from the URL path. The
  YouTube/fileData input is the documented Gemini video path; the
  guessed image MIME made valid YouTube inputs malformed.
- OpenAI Responses translator preserves `response.output` ordering
  on assistant turns that emitted both text and a function_call:
  assistant text is now serialized BEFORE the function_call item
  so the subsequent function_call_output (the matching role=tool
  follow-up) lands in the right position. Previously the order
  was function_call -> assistant text -> function_call_output,
  which can confuse multi-turn function-calling flows.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: round 20 Gemini follow-ups (PR #5720)

Convergent reviewer findings from round 20:

- tool_choice="none" no longer flips responseModalities=[TEXT,IMAGE]
  on image-tier Gemini models. Forced-function tool_choice (e.g.
  {type:function, function:{name:lookup}}) also drops hosted Search /
  code execution from the Gemini body so the caller's pinned user
  function is not silently joined by hosted builtins.

- Gemini code-execution thoughtSignature replay now uses an ordered
  parts list (native_part.parts[]) so per-part signatures stay
  attached to the exact part Gemini emitted. The previous merged
  shape fanned one top-level thoughtSignature across executableCode
  + codeExecutionResult + inlineData and tripped Gemini 3 strict
  validators. Backward-compat fallback keeps pre-round-21 persisted
  history working: a legacy native_part with a single subpart still
  replays the signature on that subpart; merged legacy objects pin
  the signature to executableCode only.

- Remote-image fetch threads the remaining per-request byte budget
  into _safe_fetch_image_for_gemini, so over-budget URLs are
  refused via Content-Length pre-check / short read instead of
  fully downloaded then discarded after the aggregate cap check.

- Gemini role=tool with OpenAI list-form content
  ([{type:text,text:result}]) now flattens text parts before
  building functionResponse.response.result; previously the parts
  arrived as the result value instead of the actual tool output.

- Frontend chat-adapter merges native_part by concatenating parts
  lists (preserving per-part thoughtSignature). Wire types expose
  enable_prompt_caching as boolean|string (Gemini cached-content
  name) and OpenAIChatDelta now carries tool_calls and extra_content.

- Test test_openrouter_no_synthetic_web_search_event_on_tool_choice_none
  reads _toolEvent from the top-level SSE payload so a backend
  regression cannot mask the assertion.

Adds 7 regression tests covering image_generation gate, forced-function
gate, native_part list replay, legacy fallback, list-content
functionResponse flattening, fetch byte-budget threading, and wire
types.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Apply forced-function tool_choice gate to Anthropic, OpenRouter, Kimi

Previously only the Gemini path treated `tool_choice={"type":"function",
"function":{"name":...}}` as a hosted-tool opt-out. Anthropic,
OpenRouter, and Kimi still attached hosted web_search / web_fetch /
code_execution when the caller explicitly pinned a user function plus
`enabled_tools=[...]`. That contradicts the explicit function pin and
bills the caller for unwanted server-side calls.

Mirror the Gemini gate symmetrically:
  - Anthropic web_search / web_fetch / code_execution
  - OpenRouter `plugins:[{id:"web"}]` + the synthetic web_search SSE
    event the same path emits at stream close
  - Kimi `_stream_kimi_web_search` dispatch

Adds 4 regression tests:
  - test_anthropic_forced_function_tool_choice_drops_hosted_tools
  - test_openrouter_forced_function_tool_choice_drops_web_plugin
  - test_kimi_forced_function_tool_choice_skips_web_search_helper
  - test_openrouter_no_synthetic_web_search_event_on_forced_function_tool_choice

All 146 existing backend tests still pass.

* Strip Gemini-only synthetic tool history on local-GGUF dispatch

After a Gemini chat that ran code_execution / image_generation, switching
the same thread to a local GGUF model used to forward the synthetic
provider-side tool_calls (tagged with `args._server_tool` or carrying a
Gemini `args.google.native_part` payload) and the message-level
`extra_content` to llama-server. The receiving backend has no tool
declaration for those names and no use for Gemini thoughtSignature
metadata; in the worst case it can produce an orphan tool_call_id and a
confused continuation.

Add `_strip_provider_synthetic_tool_history()` and wire it through the
two local message builders:
  - `_openai_messages_for_passthrough`  (OAI-compat passthrough)
  - `_openai_messages_for_gguf_chat`    (standard GGUF chat path)

Real user-function `tool_calls` and their matching `role="tool"` replies
survive unchanged; only synthetic provider-side cards and Gemini-only
`extra_content` are stripped. If the synthetic call was the assistant
turn's only payload, the now-empty turn is dropped too so llama-server
does not reject the request.

Adds 2 regression tests:
  - test_strip_provider_synthetic_tool_history_drops_synthetic_only
  - test_strip_provider_synthetic_tool_history_drops_empty_assistant

142 existing backend tests still pass.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Disable Search/Code composer pills for Gemini image-tier models

For external Gemini image-tier models (gemini-2.5-flash-image,
gemini-3.x-image-preview, etc.), the backend unconditionally strips
code_execution and strips web_search on older image ids. Search is
still allowed on Gemini 3.x Pro/Flash image models, which
supportsBuiltinWebSearch already encodes per model.

Before this commit the composer pill gates were:
  searchDisabled = !modelLoaded || !(supportsTools || supportsBuiltinWebSearch)
  codeDisabled   = !modelLoaded || !(supportsTools || supportsBuiltinCodeExecution) || imageModeDisablesCode

`supportsTools` here is a local-runtime fallback that becomes true when
any tool-capable local model has been loaded in the session. With a
local tool-capable runtime active, switching the chat to an external
Gemini image-tier model used to leave Search/Code clickable, even
though the backend will silently drop the tool on the wire.

Detect "external provider is Gemini AND the model is image-tier" (via
supportsBuiltinImageGeneration) and gate the two pills strictly on the
provider's own builtin support in that case. Non-Gemini paths and
non-image Gemini models keep the supportsTools fallback unchanged.

* Apply forced-function tool_choice gate to OpenAI Responses path

Round 22 added the gate for Gemini / Anthropic / OpenRouter / Kimi but
missed the OpenAI Responses translator. When a caller pinned a user
function via `tool_choice={"type":"function","function":{"name":...}}`
plus `enabled_tools=["web_search","code_execution","image_generation"]`,
the Responses body still attached `{"type":"web_search"}`,
`{"type":"shell"}`, and `{"type":"image_generation"}` server tools. The
function pin should suppress those for the same privacy + billing reason
the other provider paths now do.

Compute `_responses_tool_choice_forced_function` next to
`_responses_tool_choice_none` and gate each hosted-tool append on
`_responses_hosted_builtins_allowed = not none and not forced_function`.
The fix has to be applied in TWO places: the initial body builder and
`_build_body()` (called by the container-expiry retry path). User
function declarations still flow through so the pin has something to
target, and the Responses-shape `{type:"function", name:"..."}`
`tool_choice` is forwarded unchanged.

Adds regression test `test_openai_responses_forced_function_tool_choice_drops_hosted_tools`.
All 166 existing backend tests across Gemini + Responses + image-gen +
code-exec suites still pass.

* Round 24 P1s: SSRF shared-address gap + extra_content text-only leak + custom-Gemini model list

Three convergent P1s from round 24 review:

1. SSRF: the shared SSRF validator in `tools._validate_and_resolve_host`
   used a denylist (is_private / loopback / link_local / multicast /
   reserved / unspecified). Python classifies shared address space
   (100.64.0.0/10 carrier-grade NAT, plus 240.0.0.0/4, benchmarking
   ranges, etc.) with `is_private=False` AND `is_global=False`. The new
   Gemini server-side image fetcher therefore accepts URLs whose
   hostname resolves to 100.64.0.1 in cloud/VPC deployments. Add
   `not ip.is_global` as the primary gate -- a single source of truth
   that covers every current and future non-global range.

2. _strip_provider_synthetic_tool_history previously only stripped
   message-level `extra_content` when the assistant turn had tool_calls.
   A plain text Gemini reply carrying
   `extra_content.google.thought_signature` flowed through to
   llama-server when the thread was switched to a local GGUF backend.
   Always strip message-level `extra_content` on assistant turns.

3. routes/providers.list_provider_models applied Gemini's native
   `model_id_allowlist` regex to every Gemini provider, including
   custom OAI-compatible bases (LiteLLM, deployment gateways). IDs like
   `google/gemini-2.5-flash` and team-prefixed deployment aliases got
   filtered out even though the chat-dispatch path now routes them via
   the OpenAI-compatible client. Skip registry-level model-id filters
   when the configured Gemini base_url host is not the canonical
   `generativelanguage.googleapis.com`, mirroring the chat-dispatch
   gate.

Three regression tests added:
  - test_validate_and_resolve_host_blocks_shared_address_space
  - test_strip_provider_synthetic_tool_history_drops_text_only_extra_content
  - test_gemini_custom_oai_compat_base_skips_native_allowlist

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Round 25 P1s: skip synthetic server-tool replay + inline $ref/$defs into Gemini schema

Two convergent reviewer findings on the native Gemini path:

1. _stream_gemini's tool_calls replay loop falls through to a generic
   functionCall emission whenever it sees an assistant tool_call. Marked
   server-side builtin cards (web_search / web_fetch tagged with
   _server_tool or args.google.native_part) hit that fallthrough with no
   replayable native_part, which produces an outbound functionCall whose
   name is not a declared user function. The Gemini turn 400s on the
   undeclared name. Guard the loop to drop those entries instead, while
   keeping the existing code_execution / image_generation native-part
   replay branch intact.

2. _sanitize_gemini_schema uses a strict allowlist that drops local
   $ref / $defs references. Pydantic-generated tool schemas hoist nested
   object shapes into $defs and reference them via {"$ref": "#/$defs/X"},
   so a property like address: {"$ref": "#/$defs/Address"} collapsed to
   {} on the wire and the model lost the nested fields, types, and
   required keys. Resolve local #/... pointers against the schema root
   and inline the referenced subtree, with local siblings overriding
   the reference (normal JSON Schema composition) and a seen-ref guard
   for self-referential schemas.

Added regression coverage:
- test_gemini_native_skips_synthetic_server_builtin_replay
- test_function_declarations_inline_local_refs_into_gemini_schema
- test_function_declarations_inline_local_refs_in_anyof_and_items
- test_function_declarations_self_referential_schema_terminates

All 145 Gemini provider tests pass; touched provider regression set
(OpenAI Responses, code execution, image generation, Anthropic code
execution, Anthropic web_fetch) also 43/43 green.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Round 26 P1s: drop orphan Gemini functionResponse + Anthropic /messages synthetic-history strip

Reviewer round 26 surfaced two convergent asymmetric-fix bugs.

1. _stream_gemini drops a synthetic server-tool tool_call (web_search /
   web_fetch tagged _server_tool) and also replays code_execution /
   image_generation tool_calls as Gemini-native executableCode /
   codeExecutionResult / inlineData parts. The matching role="tool"
   follow-up was still falling through to the generic functionResponse
   branch, producing either an orphan functionResponse (synthetic case)
   or a duplicate response pointing at a name with no
   functionDeclarations entry (native-part case). Both forms 400 the
   next Gemini turn. Track skipped + native-replayed tool_call_ids in
   _gemini_skip_tool_result_ids and short-circuit the role="tool"
   branch on a match.

2. The Anthropic-compatible local /v1/messages route only called
   _drop_empty_assistant_sentinels on the OpenAI-translated history,
   while the sibling /v1/chat/completions and GGUF passthrough builders
   chain that with _strip_provider_synthetic_tool_history. An Anthropic
   caller replaying a prior provider-side tool_use therefore forwarded
   fake builtin tool history straight into local llama-server. Apply
   the same strip on the Anthropic route after the
   anthropic_messages_to_openai conversion.

Regression coverage added:
- test_gemini_native_skips_orphan_function_response_for_dropped_builtin
- test_gemini_native_skips_orphan_function_response_for_native_part_replay

Gemini suite 147/147; touched provider regression set 43/43.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Round 27 P1s: native_part location fallback + Gemini image request budget for base64

Two convergent reviewer findings on the native Gemini path.

1. _stream_gemini's synthetic-builtin detector at lines 3519-3524
   recognizes args.google.native_part as a server-tool marker, but
   _native_part was only loaded from tc.extra_content.google.native_part.
   A direct OpenAI-compatible API caller or imported third-party thread
   round-trips the payload through function.arguments because
   tool_calls[].extra_content is not in the OpenAI spec. The round-25
   guard then saw a synthetic builtin with no _native_part and dropped
   the entire assistant turn, so the next native Gemini request lost
   the prior executableCode / inlineData / codeExecutionResult context.
   Fall back to args.google.native_part when extra_content path is
   missing, mirroring what the synthetic detector already accepts.

2. _GEMINI_REMOTE_IMAGE_MAX_TOTAL_BYTES capped DECODED bytes at 20MB.
   Gemini receives images base64-encoded inside JSON, and base64
   inflates payload size by ~4/3. With 20MB decoded the actual JSON
   body is ~26.7MB plus prompt overhead, well over Gemini's ~20MB
   request limit. Drop the decoded cap to 14MB so realistic multi-
   image turns stay safely under 20MB encoded.

Added regression test test_gemini_native_part_falls_back_to_args_google
covering an OpenAI-compat-shaped image_generation tool_call whose
native_part lives only in function.arguments.

Gemini suite 148/148.

* Fix TS build errors from main merge: restore imageParts + refusal return [] + cast image-edit ref

Three errors in chat-adapter.ts surfaced by the frontend tsc step after merging
main into feat/gemini-provider:

1. The Anthropic refusal early-return used main's  but
   toOpenAIMessages returns SerializedMessage[]; flip to .
2. Restore  -- the line
   was lost when removing main's conflict block from the function body.
3. selectedImageEditReference splice was inserting OpenAIChatMessage
   into a SerializedMessage[] array; the shapes differ on tool_calls.id
   nullability. Cast the reference message through unknown -- it carries
   no tool_calls, so the runtime payload is structurally compatible.

Reproduced locally with `tsc -b --pretty false` (now passes). Build
also failing in the in-repo `npm run build` step on PR CI; this commit
unblocks all 12 failing UI/API workflows.

* Tighten verbose comments in external_provider.py + chat-adapter.ts

Compress multi-line explanatory comments in the Gemini translator
and the chat adapter without changing any behaviour. All 148 Gemini
provider tests still pass; tsc --noEmit clean.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@users.noreply.github.com>
2026-05-27 06:01:24 -07:00
Lee Jackson
abeabc71bb
Studio: expand Connections model picker for local inference server (#5643)
* feat: add custom model v1/model loading

* fix: require base URL for local model catalog loading

---------

Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
2026-05-20 15:06:06 +04:00
Lee Jackson
920920592e
Polish/cloud to providers (#5450)
* polish: update provider dropdown and rename cloud

* fix: tighten custom provider fallback handling

* fix: external provider fallback typing

* studio: wire the chat Search button to OpenAI's built-in web_search tool

When the active model is an OpenAI external provider and the user
clicks the existing Search pill in the composer, the chat-completion
request now carries the unified enable_tools shorthand:

    enable_tools: true
    enabled_tools: ["web_search"]

The backend's stream_chat_completion threads enabled_tools through
to _stream_openai_responses, which translates it into the Responses
API tool schema:

    body["tools"] = [{"type": "web_search"}]

per the OpenAI Responses tool spec
(https://developers.openai.com/api/docs/guides/tools). OpenAI then
runs the search server-side before the model replies; the search-
informed answer streams back through the existing
response.output_text.delta path. web_search_call lifecycle events
are silently ignored for now — sources / status indicators are
follow-up scope.

Frontend:
- provider-capabilities.ts: new providerSupportsBuiltinWebSearch()
  helper. Returns true only for `openai` today; Anthropic
  (web_search_20250305), Gemini grounded-search, and OpenRouter
  variants can be added later with matching backend translation.
- chat-page.tsx: both model-switch paths (the onChange handler and
  the inferenceParams.checkpoint useEffect) set supportsTools to
  match the new helper, and force toolsEnabled=false on every
  external switch so the Search toggle is opt-in by default.
- chat-adapter.ts: external branch adds enable_tools +
  enabled_tools=["web_search"] to the request body when the
  toggle is on AND the active provider supports built-in
  web-search. Local-model branch is unchanged — it continues to
  route the same shorthand through our local tool runtime.

Backend:
- routes/inference.py: forwards payload.enabled_tools to
  stream_chat_completion at the proxy site (line 1599).
- external_provider.py: stream_chat_completion gains an
  enabled_tools parameter; _stream_openai_responses appends
  {"type": "web_search"} to body["tools"] when the list contains
  "web_search". Other tools (file_search, code_interpreter,
  image_generation, computer_use_preview) are easy follow-ups in
  the same block.

Reuses the existing pydantic ChatCompletionRequest.enabled_tools
field, so no schema migrations.

* studio/backend: surface OpenAI server-side web_search in the chat UI

When the user has the chat Search button toggled on and OpenAI's
/v1/responses invokes the built-in web_search tool, _stream_openai_responses
now translates the tool's lifecycle events and citation annotations
into the same _toolEvent shape that local-tool calls use. The result:
the chat UI shows a web_search tool-call card mid-stream, then lists
the cited sources at the end of the message — identical to how local
web_search renders.

SSE event translation:

- response.output_item.added with item.type=web_search_call ->
  emit _toolEvent tool_start. Carries item.action.query as args
  when OpenAI ships it on the added event.
- response.output_item.done with item.type=web_search_call ->
  backfill the query if it only arrives on the done variant. The
  existing reasoning branch on the same event is preserved as an
  if/elif under a shared isinstance guard.
- response.output_text.annotation.added with type=url_citation ->
  collect into the most-recent web_search_call.citations list.
- response.output_text.delta with inline annotations[] (older
  API variant) -> same collection path, so both wire shapes work.
- response.completed -> emit _toolEvent tool_end per call with
  citations formatted as
    Title: <title>\nURL: <url>\nSnippet: <snippet>
  blocks joined by `\n---\n`. The frontend's
  parseSourcesFromResult already lifts this format into source
  content parts at end-of-stream.
- response.incomplete -> close out web_search cards with whatever
  citations had landed, so a truncated response does not leave a
  perpetually "running" tool card in the UI.

Both reasoning and web_search work simultaneously on the same turn —
the body sends `reasoning: {effort, summary}` and `tools: [{type:
"web_search"}]` independently, and the SSE handler tracks them
through separate channels.

Diagnostic: finally-block logger now reports per stream

  web_search_requested  - whether the client asked for it
  web_search_invocations - how many calls OpenAI actually made
  citations - total URLs cited
  queries - the search queries the model issued
  reasoning_emitted - whether <think> content was streamed

so reports of "I clicked Search and nothing happened" can be triaged
from the backend log without browser devtools.

* studio/backend: fix empty query + per-card '(no sources cited)' on OpenAI web_search

Two display bugs on the OpenAI Responses web_search → chat-UI bridge:

1. Tool cards showed "Searching for ''" — query missing.
   OpenAI's response.output_item.added for web_search_call does not
   reliably populate action.query across API versions; the canonical
   place is output_item.done. The previous code emitted tool_start
   at added with empty args and tried to backfill at done, but the
   frontend's _toolEvent: tool_start is a one-shot push (no update
   mechanism), so the args stayed empty.

   Fix: defer both tool_start *and* a placeholder tool_end emission
   to output_item.done, where action.query is guaranteed populated.
   added now just initialises tracking. Frontend then renders one
   card per call with the right "Searching for: <query>" label.

2. Every card showed "(no sources cited)".
   The previous code tried to attribute url_citation annotations
   to individual web_search_call invocations, but OpenAI's
   annotations carry no link back to a specific search call —
   they're just URLs the model cited from the aggregated search
   pool. With N invocations and M annotations, the previous logic
   bucketed all M into the last call and stamped "(no sources
   cited)" on the rest.

   Fix: collect citations into a single shared all_url_citations
   list, dedup by URL. At response.completed (and
   response.incomplete) overwrite the *last* web_search_call's
   tool_end result with the aggregated Title:/URL:/Snippet:
   blocks. The frontend's parseSourcesFromResult already flatMaps
   every web_search result, so one non-empty result is enough to
   surface the full source-pill set at the message tail. Other
   tool cards get an empty result string (no '(no sources)' text).

Diagnostic log unchanged in shape; total_citations now reads
len(all_url_citations) directly.

* studio/chat: split Code and Search pill gates so external models cannot enable Code

The previous wire-up set supportsTools=true for OpenAI external
models to light up the Search pill, but supportsTools also gates the
Code pill, so Code became clickable for OpenAI even though external
providers have no local code execution.

Separate the two gates so each pill reflects what's actually
available:

- chat-runtime-store: new `supportsBuiltinWebSearch: boolean` flag.
  Distinct from supportsTools — that one still means "runtime has a
  local tool sandbox" (Code, python, our DuckDuckGo web_search).
  This one means "the active external provider exposes a server-side
  web_search tool we can opt into" (OpenAI's /v1/responses today).
- chat-page model-switch (both code paths): for external models,
  supportsTools is now forced to false (no local Code path) and
  supportsBuiltinWebSearch follows providerSupportsBuiltinWebSearch.
  Local-model paths are unaffected — they only set supportsTools.
- shared-composer: Search pill gates on
  `searchDisabled = !modelLoaded || !(supportsTools ||
  supportsBuiltinWebSearch)`. Code pill gates on
  `codeDisabled = !modelLoaded || !supportsTools` — strictly the
  local runtime, so external models keep Code greyed out.
  A `toolsDisabled = codeDisabled` alias is left in place for any
  later-touched call site that may still reference the old name.

No backend changes — chat-adapter already calls
providerSupportsBuiltinWebSearch directly, independent of the store
flags, so the request shape and the backend translation are
unchanged.

* studio/chat: default external reasoning effort to medium, not the carry-over

When switching to an external model with reasoning support, the effort
dropdown was inheriting whatever value the user had set on a prior
model — frequently "xhigh" left over from a previous Opus/gpt-5
session. That meant every fresh OpenAI/Anthropic selection started at
Extra High, burning tokens unintentionally.

Both model-switch sites in chat-page (the useEffect on
inferenceParams.checkpoint and the onChange callback) now pick
"medium" whenever the new model's level list contains it, instead of
the clamped carry-over. The clamp still fires as a fallback for the
narrow case where a model doesn't expose medium (e.g. gpt-5.3-chat-
latest which only has medium anyway — no change there). Users can
still pick another level explicitly via the Think dropdown.

* studio/chat: also light the Search pill in the welcome-screen composer

There are two composers in the chat feature. shared-composer.tsx
renders inside an active thread, and assistant-ui/thread.tsx has its
own WebSearchToggle / CodeToolsToggle that ship the welcome-screen
"Send a message…" composer (visible before the first user message).

The previous fix split supportsTools and supportsBuiltinWebSearch in
shared-composer but never touched the welcome-screen toggles in
thread.tsx — they both still gated on supportsTools alone, so the
Search pill stayed greyed on the welcome screen even for OpenAI
external models that legitimately support web_search server-side.

Mirror the shared-composer rule in WebSearchToggle:

    disabled = !modelLoaded || !(supportsTools || supportsBuiltinWebSearch)

CodeToolsToggle is left as-is — its current
`disabled = !(modelLoaded && supportsTools)` is correct: external
models have no local code-execution sandbox, so Code stays greyed
when supportsTools=false (which is what chat-page now writes for
external selections).

* studio/backend: wire Anthropic server-side web_search end-to-end

Mirrors the OpenAI web_search integration for Anthropic's
web_search_20250305 tool. When the user toggles Search on with an
Anthropic model selected, the request now carries the documented
tool entry:

    tools: [{type: "web_search_20250305", name: "web_search",
             max_uses: 5}]

on /v1/messages, and the SSE translation surfaces tool cards +
source pills in the chat UI exactly the same way as OpenAI.

stream_chat_completion now forwards enabled_tools into the
Anthropic branch (was only doing this for the OpenAI Responses
branch). _stream_anthropic gains an enabled_tools parameter and
the web_search request-body block plus three additional event
handlers:

- content_block_start with type=server_tool_use, name=web_search:
  start tracking a new call. id becomes the tool_call_id.
- content_block_delta with type=input_json_delta inside a
  server_tool_use block: buffer the partial_json so we can read
  out the search query when the block closes.
- content_block_start with type=web_search_tool_result: capture
  the per-call result list (urls + titles) that Anthropic ships
  inline.
- content_block_stop: closes whichever block we're inside —
    * server_tool_use -> emit _toolEvent: tool_start with the
      parsed query as args.
    * web_search_tool_result -> emit _toolEvent: tool_end with
      Title:/URL: blocks the frontend's parseSourcesFromResult
      lifts into source pills.
    * thinking block -> existing </think> close.

Unlike OpenAI we get per-call results directly, so no aggregated-
last-call fallback is needed — each tool card carries its own
citations.

Diagnostic log on stream completion now reports
web_search_requested / invocations / total_results / queries,
matching the OpenAI shape.

Frontend providerSupportsBuiltinWebSearch returns true for
'anthropic' as well, so the Search pill lights up on Claude
models the same way it does on OpenAI. The existing chat-adapter
external branch already sends enabled_tools=['web_search'] based
on this helper — no adapter changes needed.

* studio: wire OpenRouter built-in web search via :online model suffix

OpenRouter exposes a universal "add web search to any model" shortcut:
append `:online` to the model id and the gateway runs the search
server-side, streaming citations back as annotations on text deltas.
Documented at https://openrouter.ai/docs/features/web-search

Hook the existing Search toggle into that path:

Backend (external_provider.py, default OAI-compat branch):
- When provider_type == 'openrouter' and enabled_tools contains
  'web_search', rewrite body['model']:
    openai/gpt-4o            -> openai/gpt-4o:online
    anthropic/claude-sonnet-4-5:free -> anthropic/claude-sonnet-4-5:online
  Any existing `:variant` (`:free`, `:nitro`, etc.) is replaced —
  OpenRouter variants are mutually exclusive.
- `openrouter/free` is skipped: it's a meta-router and `:online` is
  not a valid suffix on it (the gateway 400s).
- A one-line INFO log fires whenever the rewrite happens so the
  diagnostic backend log shows exactly which model id the request
  was promoted to.

Frontend (provider-capabilities.ts):
- providerSupportsBuiltinWebSearch now returns true for 'openrouter'
  alongside 'openai' and 'anthropic'. The Search pill lights up and
  the existing chat-adapter external branch already forwards
  enabled_tools=['web_search'] based on this helper — no adapter
  changes needed.

No new SSE event handling: OpenRouter does not emit a separate
web_search_call event the way OpenAI/Anthropic do. Citations come
back as text annotations via the existing reasoning_details path
the adapter already parses, so source data flows through without
extra translation. A per-call tool-card UX ("Searching for: …")
would require synthesizing one client-side; deferred to a follow-up
if the bare-citation flow feels too minimal.

* studio: wire Mistral built-in web search connector

Same shape as OpenAI's web_search tool, lives on
/v1/chat/completions instead of /v1/responses. When the chat
Search pill is toggled on with a Mistral model selected, the
backend now appends

    {"type": "web_search"}

to body["tools"] before the request goes out. Idempotent —
won't double-append if a future call site adds it first. Models
in the registry allowlist that don't support the connector
(codestral, devstral, ministral, mistral-tiny) will surface a
400 from upstream; the existing default-path error log captures
it. Mistral's docs:
  https://docs.mistral.ai/capabilities/agents/connectors/websearch

Frontend providerSupportsBuiltinWebSearch returns true for
'mistral' now, alongside openai / anthropic / openrouter. The
Search pill lights up for Mistral models and the existing
adapter branch already sends enabled_tools=['web_search'] off
this helper — no adapter changes.

No SSE translation yet — Mistral streams citations inline as
text annotations or `references` in the final assistant content,
not as a separate web_search_call event. Citations flow through
to the message body as text; a per-call tool-card UX with
"Searching for: …" indicators is a follow-up if needed.

* studio/backend: fix OpenRouter web_search to use plugins shape + synthesize tool card

Two changes against the actual OpenRouter docs at
https://openrouter.ai/docs/guides/features/plugins/web-search:

Request shape:

The previous commit appended :online to the model id, which works on
concrete model ids but rejects on meta-routers like openrouter/free —
and that's exactly the model the user was testing with, so neither
the request rewrite nor the diagnostic log fired. Switch to the
universal plugins shape:

    body["plugins"] = [{"id": "web"}]

Per the docs this is "exactly equivalent" to :online but works on
every model id including openrouter/free and openrouter/auto. No
model suffix manipulation, idempotent if added twice.

Tool-card synthesis:

OpenRouter doesn't emit a structured web_search_call event the way
OpenAI/Anthropic do — citations come back only as `annotations` of
type=url_citation on delta/message objects. To match the chat-UI
tool-card UX the user expects ("Searching for: …" indicator,
source pills at message tail), synthesize the events client-side
in the default OAI-compat stream loop:

- On stream open (after the 200 status check): yield a synthetic
  _toolEvent: tool_start with tool_name=web_search, fixed id
  "openrouter_web_search". The chat-UI then renders the running
  tool card before any text streams.
- During the SSE loop: scan every chunk's choices[].delta and
  choices[].message for `annotations: [{type: "url_citation",
  url_citation: {url, title, content}}]` entries. Dedup by URL
  into a citations list. Handles both the nested-url_citation
  shape OpenRouter documents and the flat-on-annotation shape
  some upstreams ship.
- On [DONE] (or stream-close without [DONE]): emit synthetic
  tool_end carrying the citations as
    Title: …\nURL: …\nSnippet: …\n---\n…
  blocks the existing parseSourcesFromResult lifts into source
  pills at message tail.

Diagnostic log on completion now also reports
web_search_requested + citation count alongside the existing
chosen-model / event-count telemetry.

* studio: drop Mistral built-in web_search — connector lives on Agents API only

Mistral's web_search is exclusively on /v1/agents + /v1/conversations;
sending it on /v1/chat/completions returns
"WebSearchTool connector is not supported". Wiring it would require a
dedicated Agents streaming path. Remove from the frontend capability map
and revert the chat-completions tool injection.

* studio: wire Kimi $web_search builtin via two-call round-trip

Kimi's $web_search lives on /v1/chat/completions but requires a client
round-trip per https://platform.kimi.ai/docs/guide/use-web-search:
the first call returns tool_calls with function.arguments populated;
the caller echoes those arguments back as a role=tool message; the
second call streams the final answer with search results incorporated.
The docs also mandate thinking=disabled while the builtin is active.

Backend: new _stream_kimi_web_search helper dispatched from
stream_chat_completion when provider_type=='kimi' and 'web_search' in
enabled_tools. Buffers tool_calls across deltas, falls back to a plain
stream if the model declines to search, and synthesizes tool_start
(with parsed query) / tool_end (with any url_citation annotations) so
the chat UI's web-search card behaves the same as other providers.

Frontend: kimi added to providerSupportsBuiltinWebSearch so the Search
pill lights up in the composer.

* studio/chat: mutual exclusion of Think + Search on Kimi composer

Kimi's $web_search builtin requires thinking=disabled per
https://platform.kimi.ai/docs/guide/use-web-search, so the two states
cannot coexist. Make the pills mutually exclusive in both composers
(shared and welcome-screen): clicking Search turns Think off; clicking
Think back on turns Search off. Default Think to on when a Kimi model
is selected — k2.6/k2.5 ship with thinking enabled out of the box.

* studio/chat: fix wrong provider var name in onChange branch

selectedProvider, not provider — TS2304 in tsc -b.

* studio/backend: add diagnostics to Kimi $web_search round-trip

Log the actual function.arguments from the first call (so we can see
the model's search query) and the second call's usage.prompt_tokens +
any annotation type names that came through. prompt_tokens spiking
above the input message length is direct proof the server injected
search results into context. annotation_types lets us learn the shape
Kimi uses for citations if/when they emit any.

* studio: per-provider defaults — Anthropic xhigh + Search on, OpenAI high + Search on, Opus 4.7 gains max

Anthropic: Think effort defaults to the highest level the model
supports (xhigh on 4.6/4.7, high on 4.5) and Search starts on, since
the web_search_20250305 tool returns structured citations end-to-end.

OpenAI: Think effort defaults to 'high' (the gpt-5.x reasoning sweet
spot for /v1/responses + web_search) and Search starts on.

Opus 4.7: 'max' added as an effort level above 'xhigh' in both
backend (_ANTHROPIC_THINKING_SPECS) and frontend (ANTHROPIC_REASONING_MODELS).

Kimi diagnostics: emit tool_end immediately after tool_start so the
web-search card transitions to 'complete' before the second-call
answer streams, log first-call args + second-call usage/prompt_tokens
+ any annotation type names, request stream_options.include_usage so
the second call exposes usage in SSE.

* studio/backend: harden Kimi fallback path with HTTPError handler + manual aiter_lines loop

Addresses PR review feedback (#5443): the no-search fallback streaming
path was using `async for response.aiter_lines()` and had no
`httpx.HTTPError` guard around the POST. Switch to the manual
__anext__ loop pattern used elsewhere in this module (avoids the
Python 3.13 + httpcore 1.0.x GeneratorExit propagation issue) and wrap
the whole request in a try/except so network failures surface as a
proper SSE error frame instead of a raw traceback.

* feat: prompt caching frontend for openai/anthropic

* studio/chat: route vLLM provider to /v1/chat/completions, not /v1/responses

vLLM's /v1/responses rebuilds messages through the loaded model's chat
template, which 400s on strict-alternation templates like Gemma 3
("Conversation roles must alternate user/assistant/..."). Stop collapsing
vllm -> openai in the frontend so the backend sees the real provider type
and falls through to the standard chat-completions path. Register vllm as
a hidden entry in PROVIDER_REGISTRY so supports_vision and provider-create
validation work without surfacing it in the cloud-provider dropdown.

* studio/chat: wire prompt caching for OpenAI and Anthropic external providers

Backend half of the prompt_caching toggle that already exists in the chat
settings panel. Scoped to OpenAI cloud (/v1/responses) and Anthropic
(/v1/messages); every other provider plumbs the flag as a no-op.

- Anthropic: attach cache_control={type:ephemeral} to the system block so
  the static prefix is reused across turns. Without the marker Anthropic
  caches nothing, so this is the only way to make the toggle do real work
  on /v1/messages.
- OpenAI: opt into prompt_cache_retention="24h" — same price as the
  default in_memory policy per the OpenAI docs, but the cache survives
  ~24 hours of idle instead of ~5-10 minutes. The model picker is
  registry-scoped to gpt-5.x / o3 / gpt-4.5, all of which accept the
  parameter (gpt-5.5+ already defaults to "24h" so it's a no-op there).
- Treats `enable_prompt_caching=None` as enabled to match the frontend
  default for both providers; pass `false` explicitly to opt out.

* studio/chat: log cache token counts on OpenAI and Anthropic stream completion

Surface cache usage in the existing "stream complete" info logs so
prompt-caching behavior can be verified by tailing the studio backend
log instead of opening the provider dashboard.

- Anthropic: latch usage from message_start (input + cache_creation +
  cache_read counts) and message_delta (output_tokens), then include in
  the per-request summary. cache_read_input_tokens > 0 confirms the
  cache_control marker on the system block is doing its job.
- OpenAI Responses: latch usage from response.completed and
  response.incomplete, extract usage.input_tokens_details.cached_tokens
  (the /v1/responses field name, not prompt_tokens_details). A non-zero
  value on turn N proves prompt_cache_retention="24h" let the prefix
  hit the cache instead of being recomputed.

* studio/backend: strip temperature/top_p for Claude 4.7 family

Anthropic Opus 4.7 removed temperature, top_p, and top_k as a launch
breaking change ("Sampling parameters removed" in the 4.7 release notes
at https://platform.claude.com/docs/en/about-claude/models/whats-new-claude-4-7).
Setting any of them to a non-default value returns 400
"<param> is deprecated for this model". The existing guard only handled
top_k; temperature was still being sent unconditionally and is now
breaking opus-4-7 requests.

Rename _ANTHROPIC_TOP_K_DEPRECATED to _ANTHROPIC_4_7_SAMPLING_REMOVED to
reflect the broader scope, omit temperature from the base body on 4.7,
and skip the thinking-mode temperature=1 override on 4.7 (still applied
on 4.5/4.6 where it's required). Existing thinking_translation tests
target 4.5/4.6 / mock the wire so they're unaffected.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio/chat: anchor Anthropic prompt cache on the latest message too

A system-only cache_control marker is a no-op when the system prompt is
empty or shorter than Anthropic's ~1024-token cache floor — caching
silently does nothing (both cache_creation and cache_read return 0).

Add a second cache_control breakpoint on the final block of the latest
conversation message so the entire prefix (system + prior turns + new
user turn) becomes eligible for caching. On turn N+1, Anthropic
rehydrates everything up through turn N's marker instead of recomputing
it. Up to 4 breakpoints are allowed per request; we use at most 2
(system + tail). Tail rebuild avoids mutating the caller's content list
so an image-bearing turn still slots cleanly into the cached prefix.

* studio/chat: gate vLLM reasoning toggle on provider config

Add a "This server runs a reasoning model" checkbox on the vLLM
provider config. When off (default), the chat Think pill stays
hidden and no enable_thinking ever reaches vLLM. When on, the
pill renders, per-turn state flows through the existing
enable_thinking plumbing, and the backend proxy lifts it onto
chat_template_kwargs.enable_thinking so vLLM's Jinja template
honours it.

* chore: clean vLLM reasoning-toggle comments

* studio/chat: gate prompt_cache_retention to actual OpenAI cloud requests

Addresses Codex P1 review on _stream_openai_responses. The frontend
only sends enable_prompt_caching for the openai/anthropic UI provider
types, so ollama/llama.cpp/"custom" requests reach this helper with
the flag as None. The previous `is not False` check treated None as
enabled and injected prompt_cache_retention="24h" into every request
including those bound for non-OpenAI servers, which would 400 on
servers that implement /v1/responses but not the retention parameter.

Match the public OpenAI host (api.openai.com) on the client base_url
before adding the field so it only lands on actual OpenAI cloud
requests. Studio's openai picker is already registry-scoped to
gpt-5.x / o3 / gpt-4.5, all of which accept the parameter.

---------

Co-authored-by: Roland Tannous <rolandtannous@gravityq.ai>
Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-15 19:29:21 +04:00
Roland Tannous
9a0d6f80cb
studio: API external provider support for chat (OpenAI, Mistral, Gemini, Cohere, Anthropic, OpenRouter, DeepSeek, custom providers) (#4706)
* 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
  <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 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: <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 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 '<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>
2026-05-14 16:13:59 +04:00