Commit graph

749 commits

Author SHA1 Message Date
pre-commit-ci[bot]
aee1b7b9c1 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 16:26:08 +00:00
Daniel Han
ad36aaa71d Local /v1/messages: invert disable_parallel_tool_use into parallel_tool_calls (PR #5711)
Anthropic Messages API nests `disable_parallel_tool_use` inside the
`tool_choice` object (per docs.claude.com/parallel-tool-use). The local
Anthropic-compat endpoint dropped that flag because the OpenAI shape it
translates into uses a different name and lives at the top level
instead. SDK clients (anthropic-python, anthropic-sdk-go, etc.) that
already speak this dialect therefore could not opt out of parallel
tool calls against the local GGUF model.

Extract `disable_parallel_tool_use` from the incoming tool_choice and
invert it to `parallel_tool_calls` on the agentic-loop call. Plain-chat
and existing tool_choice shapes are untouched. Added a focused unit
test that pins the dict/None/bool/string boundary cases.
2026-05-24 16:23:23 +00:00
Daniel Han
d7a09d975b Drop seed and parallel_tool_calls for Kimi too (PR #5711)
Kimi K2.5/K2.6 chat schema documents temperature, top_p and a small
fixed set of knobs; seed and parallel_tool_calls are not in it. The
frontend already hides those controls (provider-capabilities.ts), so
the only way they reach Kimi is a stale client or a direct API caller.
Add them to body_omit so the registry strips them on the wire instead
of relying on the upstream to 400.

Sync the Kimi web-search bypass test to assert both fields are dropped
alongside frequency_penalty/temperature/top_p.
2026-05-24 16:14:12 +00:00
pre-commit-ci[bot]
fbdd4e58e0 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 15:55:38 +00:00
Daniel Han
e0a9b1d76a Drop Kimi frequency_penalty and gate generic service_tier on opt-in
5/10 reviewers in the last round flagged Kimi forwarding non-default frequency_penalty as a 400 risk for K2.5 / K2.6, mirroring the existing lock on temperature and top_p. Hide the slider on the frontend and add frequency_penalty to Kimi's body_omit so even stale clients have the field stripped before the request hits the wire.

service_tier on the generic OpenAI-compatible branch was forwarding whatever value the dispatcher received, so a stale frontend could send standard_only (Anthropic) or scale to providers like Mistral that do not document the field, producing 400s. Gate the forward on an explicit accepts_service_tier=True provider registry opt-in; Anthropic and OpenAI Responses already handle service_tier inside their own helpers.
2026-05-24 15:54:26 +00:00
Daniel Han
1d1a205a19 OpenRouter stop cap is 4, GGUF tool-loop final pass forwards new fields
OpenRouter normalises to OpenAI's chat schema and inherits the 4-entry stop cap. The default 16-cap was too permissive; add stop_max=4 on both the backend provider registry and the frontend PROVIDER_STOP_MAX map.

The GGUF tool-iteration final-answer pass at llama_cpp.py:5182 was carrying only the legacy sampling fields. Forward frequency_penalty, seed, and parallel_tool_calls there too so the cap-exhausted path matches the per-iteration loop.

Test pins the OpenRouter 4-cap.
2026-05-24 15:33:42 +00:00
pre-commit-ci[bot]
1d3d7ef39c [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 15:06:29 +00:00
Daniel Han
1cc52465f3 Kimi 32-byte per-stop cap; extract _normalize_stop_for_provider helper
Kimi documents max 5 stop strings AND <= 32 bytes per string at
https://platform.kimi.ai/docs/api/chat. The previous code capped
count but forwarded oversize entries, which can produce upstream
400s. Add stop_max_bytes=32 on the Kimi registry entry and apply
both checks in a new _normalize_stop_for_provider helper shared
between the default OAI-compat path and the Kimi web-search bypass.

Tests pin the byte-cap drop on both Kimi paths.
2026-05-24 15:06:15 +00:00
Daniel Han
95e143545f Per-provider stop cap on Kimi web-search bypass and frontend sheet
Round 5 review flagged two asymmetries:

1. Kimi web-search bypass hard-capped stops at 4 while the default OAI-compat path honours provider_info["stop_max"]. Apply the same provider-aware logic in _stream_kimi_web_search so kimi-with-search and kimi-without-search match. Also add Kimi's documented 5-stop max (https://platform.kimi.ai/docs/api/chat) to the provider registry so the cap actually fires.

2. chat-settings-sheet.tsx caps every non-Anthropic external provider at 4 stops. Replace with a per-provider getProviderStopMax helper in provider-capabilities.ts so DeepSeek, Mistral, and local backends are not artificially restricted while OpenAI Chat still hits its 4-entry hard limit and Kimi hits its documented 5-entry cap.

Tests pin the Kimi 5-cap on both Kimi paths.
2026-05-24 14:50:04 +00:00
pre-commit-ci[bot]
f200bc20c0 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 14:33:40 +00:00
Daniel Han
b48d68f8bf Fix Mistral seed mapping, raise default OAI-compat stop cap, thread sampling through GGUF direct path
Mistral chat completions uses random_seed not seed; map the field via a new seed_field on the provider registry so the new seed control actually works on Mistral. Default for other providers stays seed.

DeepSeek and Mistral both accept up to 16 stop sequences but the default OAI-compat branch was hard-capping at 4 (the OpenAI Chat limit). Studio routes the openai provider through /v1/responses not /v1/chat/completions so the 4-cap only applies if we explicitly added an openai entry. Raise the default to 16 and let per-provider stop_max overrides tighten if needed.

The local GGUF direct chat path (gguf_generate / gguf_generate_with_tools) bypassed _build_openai_passthrough_body and therefore dropped frequency_penalty, seed, stop, and parallel_tool_calls on the floor for users on the default no-tools and with-tools paths. Thread the new fields through LlamaCppBackend.generate_chat_completion and generate_chat_completion_with_tools and the two callsites that invoke them.

Also tighten comments to drop review-process narration that crept in and to remove the em dashes I had introduced in this PR's earlier commits.

Tests pin the Mistral random_seed rename, the DeepSeek 16-cap, and confirm the openai-compat default cap is 16.
2026-05-24 14:32:36 +00:00
Daniel Han
30d6ce201e Studio: drop service_tier=scale on OpenAI Responses path
Round 4 reviewer consensus (~9/20 independent reviewers) flagged
service_tier=scale as a 400 risk on /v1/responses. The earlier commit
added scale based on the openai-python SDK literal, but the live
OpenAI Responses API reference, the PR's own provider matrix, and the
9-reviewer round-4 consensus all agree the documented Responses enum
is auto|default|flex|priority only. Drop scale on this path to remove
the risk.

Keeps scale on the Chat Completions / OAI-compat path where the SDK
enum is honored and where users who want Scale Tier can still select
it. The widened TypeScript ServiceTier / ServiceTierOption / api.ts
union and the storage sanitizer allowlist remain permissive so legacy
persisted "scale" values do not get silently dropped on reload; the
runtime per-provider gate makes the routing decision.

Tests are updated to pin the restricted Responses enum and the
explicit drop of scale + standard_only + bogus values.
2026-05-24 14:12:13 +00:00
Daniel Han
b8cef29b50 Studio: forward parallel_tool_calls through /v1/responses bridge
Round 3 reviewer feedback:

- studio/backend/routes/inference.py: _build_chat_request (the
  /v1/responses → /v1/chat/completions translator) was dropping
  parallel_tool_calls on the floor. A Responses-API caller that set
  `parallel_tool_calls=false` saw the flag accepted at the schema
  layer but never reach llama-server because the translated
  ChatCompletionRequest had no first-class field for it. Now that
  parallel_tool_calls IS a first-class field on ChatCompletionRequest
  (added by this PR's earlier commits), translate it through the
  bridge so the preference actually fires.

- studio/frontend/src/features/chat/utils/chat-settings-storage.ts:
  the stop sanitizer silently dropped `stop: []` instead of persisting
  the empty array. That meant a user could not clear the last chip —
  on reload, the previously-persisted stops came back. Persist empty
  arrays explicitly so the cleared state round-trips.

- studio/backend/tests/test_sampling_params_routing.py: pin both with
  the raw reproductions reviewers cited.
2026-05-24 13:57:46 +00:00
Daniel Han
d8a4627355 Studio: widen scale type, preserve significant ws in stops, Kimi parity
Round 2 reviewer feedback:

- studio/frontend/src/features/chat/types/api.ts: `OpenAIChatCompletionsRequest.service_tier` did not include `"scale"`, so the request builder in chat-adapter.ts failed typecheck after the runtime ServiceTier union widened (`Type 'ServiceTier | undefined' is not assignable...`). Widen the type to match the SDK and keep the typecheck green.

- studio/frontend/src/components/ui/stop-sequences-input.tsx: the chip editor used `draft.trim()` for storage, which silently mutated semantically meaningful stops like " End", "### ", and "\n\n". Keep the whitespace-only rejection (Anthropic 400s on those, OpenAI silently drops them) but persist the raw draft so leading/trailing whitespace inside otherwise-meaningful stops survives.

- studio/backend/core/inference/external_provider.py: the Kimi web-search bypass dropped a single string `stop="\n\n"` via `stop.strip()` while the normal default OAI-compat path forwards it verbatim. Mirror the default path's behavior here so kimi-with-search and kimi-without-search apply the same rules (asymmetric provider-path fix flagged in round-2 review).
2026-05-24 13:44:42 +00:00
pre-commit-ci[bot]
fdf0be484e [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 12:45:36 +00:00
Daniel Han
d6765fddce Studio: thread sampling extensions through local + Kimi-search paths
Round-2 round of review-feedback fixes for the sampling-knobs PR:

- studio/backend/routes/chat_history.py: ChatInferenceSettings still had
  the pre-PR field list with extra="forbid", so every settings save the
  new frontend issued would 422 on the new keys (frequencyPenalty,
  seed, stop, serviceTier, parallelToolCalls). Add the fields with the
  same range / enum constraints the chat-completions schema uses, so
  the settings-persistence path round-trips cleanly.

- studio/backend/routes/inference.py: _build_passthrough_payload and
  _build_openai_passthrough_body now thread frequency_penalty, seed,
  and parallel_tool_calls through to llama-server. The frontend exposes
  these knobs for local backends; without the forwarding the UI was a
  decoration. Each field is gated on `is not None` so 0 / False / "0"
  still reach the body.

- studio/backend/core/inference/external_provider.py: the Kimi
  $web_search bypass takes an early return into _stream_kimi_web_search
  before the default OAI-compat body builder runs, so the new sampling
  fields never landed on Kimi-with-search. Forward them through the
  helper, with the same dedupe / truncate behavior the main path
  applies to `stop`. Also extend the OpenAI Responses service_tier
  allowlist to include `scale` per the live openai-python SDK
  (response_create_params.py declares
  Literal["auto","default","flex","scale","priority"]).

- studio/frontend/src/features/chat/provider-capabilities.ts +
  types/runtime.ts: add `scale` to ServiceTier / ServiceTierOption and
  surface it on the OpenAI Responses options so the UI matches the
  upstream enum.

- studio/backend/tests/test_sampling_params_routing.py: add tests for
  every gap above: Kimi web-search bypass forwarding, local OpenAI
  passthrough forwarding, ChatSettingsPayload round-trip, and the full
  Responses service_tier enum (parametrized over the five accepted
  values plus a drop check for the Anthropic-only standard_only).
2026-05-24 12:45:11 +00:00
pre-commit-ci[bot]
3ef64c2d65 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 12:37:52 +00:00
Daniel Han
d6b4c36e0a Studio: nest disable_parallel_tool_use, drop ws-only stops, fix persistence
Anthropic Messages API rejects `disable_parallel_tool_use` as a
top-level field; it is only accepted as a property on the `tool_choice`
object. Move the inversion into a tool_choice merge that defaults to
`{type:"auto"}` when no choice is supplied, and skip the field entirely
when no tools are defined (it is a no-op without tools).

The same path also dropped `stop` chips that contain only whitespace,
because Anthropic 400s with `each stop sequence must contain
non-whitespace` on entries like " ", "\n", and "\n\n". The previous
filter only dropped truly empty strings; switch to `s.strip()` so the
common newline-stop defaults are also filtered out client-side.

Frontend persistence had three round-trip data-loss bugs:

  - `VALID_SERVICE_TIERS` was missing `standard_only`, so any Anthropic
    user who picked that tier lost it on the next reload.
  - The settings sanitizer truncated `stop` to 4 entries on save,
    which defeated the Anthropic UI cap of 16. Use 16 here and let the
    per-provider stream helper cap to the wire's allowed length.
  - The chat-settings sheet's `stopMaxEntries` capped local backends
    (llama.cpp / vLLM / ollama / generic OpenAI-compat) at 4 even
    though those backends happily accept more. Match Anthropic's 16
    for the local path.

Preset policy now carries `frequencyPenalty` and `stop` so a saved
preset can fix a user's preferred decoding style. `seed`,
`serviceTier`, and `parallelToolCalls` stay out of presets because
they are per-request determinism / per-provider account / per-tool
state, not reusable preset values.

Drops the test that pinned the buggy top-level placement of
`disable_parallel_tool_use` and adds two tests for the nested shape
plus the without-tools skip path, plus a test pinning the
whitespace-stop filter against the documented Anthropic error.
2026-05-24 12:37:28 +00:00
Daniel Han
eefe40a6bf Studio: assert promoted fields on attribute path in test_extra_fields_accepted
This PR promoted frequency_penalty and seed from undeclared
chat-completion extras into explicit ChatCompletionRequest fields,
so they ride the attribute path now, not model_extra. The test
still asserted both via model_extra and failed on Linux Python
3.10-3.13 with 'assert None == 0.5'. response_format stays in
model_extra (still undeclared) so the extra='allow' contract is
covered by that branch.
2026-05-23 15:33:14 +00:00
pre-commit-ci[bot]
093f465620 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-23 15:33:14 +00:00
Daniel Han
807165810f Address review feedback on sampling-params knobs
- Drop `scale` from the OpenAI service-tier picker (frontend types and
  picker option list). OpenAI in Studio routes through `/v1/responses`,
  which does not accept `scale`; offering it in the UI silently
  dropped the value at the backend and misled users into thinking
  their selection was applied. Backend Literal still accepts it on
  input so stale clients are not 422'd, and `_stream_openai_responses`
  continues to drop it from the wire body.
- Dedupe + drop empty entries for OpenAI Chat `stop` and Anthropic
  `stop_sequences` before forwarding so whitespace chips or accidental
  repeats do not waste the 4-entry OpenAI cap or the 16-entry
  Anthropic cap. Anthropic over-cap now logs and truncates, matching
  the OpenAI path.
- Static `aria-label="Parallel tool calls"` on the Switch; screen
  readers already announce checked / unchecked state, so the dynamic
  Enable/Disable label was redundant.
- Forward an `aria-label` onto the inner Input inside
  `StopSequencesInput` so screen-reader users can identify the field.
- Regression tests covering the new dedup, truncation, and the
  preserved silent-drop of `scale` on Responses.
2026-05-23 15:33:14 +00:00
pre-commit-ci[bot]
3cd3a64088 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-23 15:33:13 +00:00
Daniel Han
91d04741ff Studio: expose Anthropic / OpenAI sampling knobs per provider
Adds the missing sampling parameters that the upstream APIs accept and
that Studio's chat UI previously hid. Each knob is gated per provider
so the picker never offers a field the upstream would 400 on, and the
per-provider stream functions translate / drop fields to match each
API's naming.

New `InferenceParams` fields (round-trip through PersistedInferenceParams
and the chat-settings server store automatically):

- frequencyPenalty (-2..2): OpenAI Chat Completions only.
- seed (int | null): OpenAI Chat + OpenAI-compat local backends.
- stop (string[]): all OpenAI Chat + Anthropic Messages. Backend
  truncates to 4 entries on OpenAI Chat per docs and renames to
  `stop_sequences` on Anthropic.
- serviceTier (auto|default|flex|priority|scale|standard_only):
  per-provider enum sets resolved by getServiceTierOptions.
- parallelToolCalls (bool, default true): forwarded as
  `parallel_tool_calls` on both OpenAI APIs and inverted into
  `disable_parallel_tool_use` on Anthropic.

OpenAI Responses (gpt-5.x / o3) explicitly drops frequencyPenalty /
seed / stop alongside the existing temperature / top_p drop, since
the upstream 400s on all of them. service_tier on Responses accepts a
subset (no `scale`) which the dispatch already enforces.

UI rows land in the existing Sampling section of the chat settings
sheet using ParamSlider (frequency penalty), a numeric Input (seed),
a new chips editor `StopSequencesInput` (stop), Select (service tier),
and Switch (parallel tool calls). Each row's visibility follows the
new ProviderCapabilities flag.

Tests pin the gating contract: stop_sequences renamed on Anthropic,
4-entry truncation on OpenAI Chat, every Responses-rejected field
dropped, schema-level validation for the service_tier Literal and
frequency_penalty range.

Plan: plans/hashed-riding-porcupine.md
2026-05-23 15:33:13 +00:00
Daniel Han
83b20976f7
ci: unblock Studio Windows + Linux + Mac smoke (#5741)
Bundles three independent CI regressions hitting the maintainer PR
backlog. Each one is verified end-to-end on a staging fork against
real Ubuntu / macOS / Windows GitHub-hosted runners before this
lands.

1. Windows --no-torch install: pydantic + pydantic-core drift to
   incompatible versions under `uv pip install --no-deps -r
   no-torch-runtime.txt` because pip resolves each independently
   from latest. pydantic.VERSION 2.13.4 pins pydantic-core==2.46.4
   but pydantic-core 2.47.0 was the freshest published wheel, so
   `import pydantic` raised
   `SystemError: pydantic-core 2.47.0 is incompatible with the
   current pydantic version`. Resolve pydantic WITH deps in a
   focused pip call (install.sh, install.ps1,
   install_python_stack.py) before the --no-deps no-torch-runtime
   pass so pip pins pydantic-core to the version pydantic declares.
   pydantic's transitive deps (annotated-types, pydantic-core,
   typing-extensions, typing-inspection) are torch-free. Drop the
   redundant `Patch Studio venv with full typer / pydantic dep
   trees` workaround from the four Windows smoke YAMLs.
   Supersedes #5733 + #5734.

2. Linux Studio Update CI: upstream llama.cpp b9261+ split each
   binary's entry code into a paired `libllama-<binary>-impl.so`
   shared library. `llama-server` and `llama-quantize` NEEDED-link
   against `libllama-server-impl.so` / `libllama-quantize-impl.so`
   with RUNPATH `$ORIGIN`, so the prebuilt overlay must copy those
   alongside the binaries. Without that, ldd reports them missing,
   preflight rejects, the installer falls back to source build, and
   studio-update-smoke annotates `setup.sh idempotency regressed`.
   Add `libllama-*-impl.so*` to the Linux runtime patterns and lock
   the pattern in test_rocm_support.TestRuntimePatterns.

3. Mac Studio UI Chat: change-password submit clicked while
   disabled. The disable gate only checked new + confirm password
   length, but Playwright's first click landed before the
   current-password field's React state had committed, so the form
   was simultaneously logically-invalid (current_password empty) and
   the button was disabled. Tighten the gate to require
   `currentPassword.length >= 8` and mirror the same check in the
   submit handler so Enter / autofill cannot bypass.
   Supersedes #5738.
2026-05-23 06:59:16 -07:00
Daniel Han
ebe504b558
Studio: PDF / document attachments for Anthropic + OpenAI (#5689)
* Studio: PDF / document attachments for Anthropic + OpenAI

Studio's local-GGUF chat already supports image attachments via the
`image_url` content part shape. PDFs and other documents had no
plumbing for the external-provider path: there was no normalised
content type the frontend could send that translated to Anthropic's
native `document` block or OpenAI's `input_file`.

Add a Studio-side `input_document` content part on assistant /
user messages with three shapes:

  {type: "input_document",
   file_data: "data:application/pdf;base64,<DATA>",
   filename?: "name.pdf",
   media_type?: "application/pdf"}

  {type: "input_document",
   file_url: "https://example.com/doc.pdf",
   filename?: "doc.pdf"}

Translation:

- Anthropic Messages API: emits a `document` block with
  `{source: {type:"base64", media_type, data}}` or
  `{source: {type:"url", url}}`, plus an optional `title` from
  `filename`. PDFs are extracted server-side by Anthropic per their
  vision/document docs and counted toward input tokens.
- OpenAI Responses API: emits `{type:"input_file", file_data |
  file_url, filename?}`. PDFs are extracted server-side.

Empty / unparseable `input_document` parts are silently dropped so
a malformed frontend payload can't blow up the request.

Tests:

- New `test_multimodal_document.py` with 6 cases pinning the
  outbound body shape for base64 + URL inputs on both providers,
  and the empty-part drop behavior on both.
- The Anthropic assertions strip the prompt-cache wrapper
  (`cache_control:{type:ephemeral}` that the tail-message caching
  layer adds) before comparing the document core fields, so this
  test stays focused on the translation, not the caching layer.

Live verified end-to-end against both providers: a 363-byte
single-page "HELLO" PDF, base64-encoded, attached as a `document`
block to Opus 4.7 and as an `input_file` to gpt-5.5. Both models
correctly extracted the word "HELLO" from the PDF.

Follow-up (out of scope):

- Pydantic schema entry on ChatMessage.content for `input_document`
  (today it rides through because ChatCompletionRequest uses
  extra=allow). Will tighten when the frontend attach button lands.
- Frontend file-picker UX for non-image attachments on the external
  provider path.

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

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

* Address review: gate empty-content msg + skip empty data-URI payload

Gemini High + Codex P2 on PR #5689:

1. Anthropic translation appended an empty `anthropic_parts` array
   when every part was dropped (e.g. user sent only an unparseable
   input_document). Anthropic 400s on "messages.N.content: at least
   one block is required". Skip the whole-message append when no
   parts survived. The OpenAI Responses path already had the
   equivalent guard, so this brings the two providers into parity.

2. `data:application/pdf;base64,` with no payload (or whitespace-only)
   parses to an empty `source.data` string. Anthropic rejects that
   with 400 as well. Skip the document block before constructing it.

Plus 2 new test cases pinning both behaviors:

- `test_anthropic_empty_only_document_drops_whole_message`: confirms
  a turn whose only content is an unparseable input_document does
  NOT make it onto the outbound `messages` array.
- `test_anthropic_empty_data_uri_payload_is_dropped`: confirms an
  empty-payload data-URI is filtered out at translation time.

(Note re: gemini's other High note about adding `input_document` to
the Pydantic ContentPart union -- ChatCompletionRequest is configured
with `extra=allow` so the part rides through today. Tightening the
union belongs with the frontend attach-button PR that surfaces the
field; called out as follow-up in the PR description.)

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

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

* Address review: register input_document in ContentPart + builder

Reviewer caught that the translation code on the external_provider
side was unreachable from a real ChatCompletionRequest:

- ContentPart is a discriminated Union of (text, image_url) only, so
  any `{"type": "input_document", ...}` part was rejected by Pydantic
  at request parsing with a discriminator error before the helper
  could see it.
- _build_external_messages in routes/inference.py only walked text
  and image_url parts, so even with a permissive schema the document
  parts would have been silently dropped instead of forwarded to
  the per-provider translator.

Fixes:

- Add InputDocumentContentPart with optional file_data / file_url /
  filename / media_type and Tag("input_document") on the Union.
- Extend _build_external_messages to pass input_document through as
  a plain dict for vision-capable providers (so external_provider's
  existing Anthropic `document` and OpenAI Responses `input_file`
  mappers actually run) and strip them on non-vision providers.

Tests added: schema accepts input_document, builder passes it to
vision providers, builder strips it on non-vision providers.

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

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

* Address review: validate file_data before preferring over file_url

Codex P2 caught that the OpenAI input_document translator treats any
truthy file_data as valid and never falls back to file_url. That
means a malformed `data:application/pdf;base64,` (empty payload) or
a whitespace-only data URI gets forwarded as `file_data=""` and
400s the whole turn, AND silently discards a perfectly recoverable
file_url on the same part.

Mirror the Anthropic-side guard onto the OpenAI Responses path:
treat any "data:" URI with no actual base64 payload as missing and
fall through to file_url. Standalone-empty data URIs (no fallback)
are dropped entirely instead of being sent to the wire.

Tests added: empty data URI + valid file_url -> file_url wins,
whitespace-only data URI + valid file_url -> file_url wins,
empty data URI without fallback -> part is dropped.

* Address review: Anthropic side also falls back to file_url on empty data URI

Codex P2 follow-up to my earlier fix: I added the empty-data-URI ->
file_url fallback to the OpenAI Responses translator but missed
the Anthropic translator, which still `continue`d on empty payloads
and discarded an otherwise valid file_url on the same part. Result:
when the frontend supplied both file_data (placeholder / broken)
AND a working file_url, Anthropic silently lost the attachment;
when the message contained only that part, the whole message could
be dropped before reaching the wire.

Mirrored the OpenAI guard: any "data:" URI with no actual base64
payload (`data:application/pdf;base64,` or whitespace-only) is
treated as missing, and the file_url branch takes over. The
all-parts-dropped guard further down already handles the
no-fallback case.

Tests added: empty data URI + valid file_url -> URL source on the
wire with the filename preserved; whitespace-only data URI + valid
file_url -> URL source on the wire.

* Address review: gate input_document passthrough to anthropic + openai

Codex P1: only `_stream_anthropic` and `_stream_openai_responses`
have explicit translation logic for input_document parts (the former
maps to {type:"document", source:...}, the latter to
{type:"input_file", file_data|file_url}). Every other provider
(gemini / mistral / kimi / openrouter / deepseek / qwen / custom)
goes through the generic /chat/completions passthrough that forwards
`messages` verbatim, so any input_document part on a non-vision
route on those providers would 400 with an unknown content_part
type.

Added `_INPUT_DOCUMENT_PROVIDERS = frozenset({"anthropic", "openai"})`
constant and gated the pass-through branch on `provider_type in
_INPUT_DOCUMENT_PROVIDERS`. Every other provider strips the part
(text content survives). Threaded provider_type through from
_proxy_to_external_provider's call site.

Tests updated: vision + provider in {anthropic, openai} still
forwards; six unmapped providers (gemini/mistral/kimi/openrouter/
deepseek/qwen) strip the part; missing provider_type strips
defensively. The existing non-vision drop test still passes.

* Fix stale web_fetch tool-version assertion after merging main

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-22 06:22:57 -07:00
Daniel Han
e86f3c5dc7
Studio: wire OpenAI Responses server-side context compaction (#5687)
* Studio: wire OpenAI Responses server-side context compaction

The OpenAI Responses API accepts a `context_management` field that
enables server-side compaction. When the rendered prompt crosses the
configured threshold, the API runs a server-side compaction step and
the request continues against the compacted prefix. No beta header
and no dated version pin are required, per the docs.

Changes:

- Add `compaction_threshold: Optional[int]` (ge=1_000, le=2_000_000)
  to ChatCompletionRequest. Thread through `routes/inference.py` ->
  `stream_chat_completion` -> `_stream_openai_responses`.
- In `_stream_openai_responses`, when threshold is set AND the base
  URL points at cloud OpenAI (api.openai.com), attach
  `context_management: [{type:"compaction", compact_threshold:N}]`
  to the outbound body. Non-cloud bases (ollama, llama.cpp, "custom"
  presets) silently drop the field so we don't 400 those servers.
- Add `test_openai_compaction.py` with 4 cases: cloud OpenAI sets
  the field verbatim, low-threshold probe passes through (we don't
  clamp on the OpenAI side because the API accepts whatever),
  non-cloud base drops the field, omitted threshold leaves body
  untouched.

Live verified against the real OpenAI API on gpt-5.5:
`context_management:[{type:"compaction", compact_threshold:200000}]`
returns 200 with no error.

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

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

* Address review: accept Azure OpenAI base URLs + raise compaction floor

Two reviewer follow-ups on the OpenAI compaction PR:

1. The `is_openai_cloud = "api.openai.com" in self.base_url` check
   excluded Azure OpenAI Foundry, even though Azure exposes the
   same /v1/responses extensions (context_management,
   prompt_cache_retention, container shell). Users on Azure saw
   their compaction toggle silently no-op. Broadened the check to
   also match `*.openai.azure.com` and made it case-insensitive so
   URLs copy-pasted from the Azure portal still resolve. Non-cloud
   OpenAI-compatible servers (ollama / llama.cpp / vLLM / "custom"
   preset) still fall outside the gate.

2. The schema floor on compaction_threshold was ge=1_000, which is
   well below the upstream Responses API's effective minimum
   (vercel/ai#12486, langchain-ai/langchain#35464 report
   `compact_threshold is not enabled` 400s on Azure at 100k; cloud
   uses 200k as the canonical example). Raised the floor to 10k
   so obvious typos surface as a clean 422 from FastAPI rather than
   an opaque upstream 400 the user has to debug from the SSE
   stream.

Tests added: Azure base URL carries both context_management and
prompt_cache_retention; mixed-case Azure URLs match; schema rejects
9_999 and accepts 10_000.

* Address review: drop schema-level compaction floor (cross-provider regression)

Codex P2 follow-up on the previous floor bump: ge=10_000 was
enforced globally at the ChatCompletionRequest layer, but the field
is documented as a no-op on every non-cloud OpenAI base and every
non-OpenAI provider. With the global floor, an Anthropic / ollama
/ llama.cpp / custom request that happens to carry compaction_threshold
below 10k was rejected with 422 at request validation time instead
of being silently ignored as the description promised.

Reverted the schema floor to ge=1 (any positive int) and rewrote
the description to call out per-provider routing: OpenAI cloud's
effective floor is around 200k and surfaces upstream 400s below
that; _stream_anthropic clamps sub-50k values up. Per-provider
helpers stay the single source of truth on the floor.

Test updated to pin: zero is still rejected, but every positive
value (1, 5_000, 9_999, 10_000, 200_000) passes schema validation.

* Address CodeQL: hostname-anchored OpenAI cloud detection

CodeQL py/incomplete-url-substring-sanitization fired on
`".openai.azure.com" in _base`. An attacker who controls the
configured base_url could slip cloud-only request body fields
(prompt_cache_retention, context_management compaction, container
shell) to an arbitrary server with:

  https://evil.com/api.openai.com/v1
  https://api.openai.com.attacker.com/v1
  https://attacker.com/.openai.azure.com/v1
  https://my-resource.openai.azure.com.attacker.com/openai/v1

Replaced the substring check with a `_is_openai_family_cloud`
helper that runs urllib.parse.urlparse on the URL and matches the
lowercased hostname exactly (`api.openai.com`) or via `endswith`
on the leading-dot suffix (`.openai.azure.com`). Both halves are
host-anchored so path / fake-subdomain bypasses fail.

Test added: every attacker-controlled bypass shape above must NOT
carry context_management OR prompt_cache_retention on the wire.
Existing Azure and openai.com tests still pass.

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

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

* Address review: scope compaction_threshold description to OpenAI on this branch

Codex P2: the field description on this PR mentioned Anthropic
compaction behavior, but the Anthropic wiring lives on PR 5686
(separate branch). On feat/openai-compaction alone, _stream_anthropic
has no compaction_threshold parameter, so the field is silently
ignored for Anthropic requests and the doc claim was misleading.

Trimmed the description to OpenAI cloud + Azure Foundry only on
this branch. PR 5686 already re-adds the Anthropic clause via its
own change, so the rebase / merge order on main will land the
combined description naturally once both PRs ship.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-22 06:20:45 -07:00
Daniel Han
9a737facaf
Studio: wire Anthropic server-side context compaction (#5686)
* Studio: wire Anthropic server-side context compaction

Anthropic ships server-side context compaction as a beta
(`compact-2026-01-12`). When the rendered prompt crosses the
configured input-token threshold, Anthropic runs an extra LLM pass
that summarises older turns and the request continues against the
compacted prefix. The response carries the original top-level fields
plus a new `context_management` block (with `applied_edits`) and
`usage.iterations[]` accounting per pass.

Per the docs the feature is currently supported on Opus 4.6, Opus 4.7,
Sonnet 4.6, and Mythos preview. The minimum threshold is 50k tokens;
under-50k requests 400.

Changes:

- Add prefix gate + helper `_anthropic_supports_compaction` plus
  constants `_ANTHROPIC_COMPACTION_PREFIXES`, `_ANTHROPIC_COMPACTION_BETA`,
  `_ANTHROPIC_COMPACTION_TYPE`, `_ANTHROPIC_COMPACTION_MIN`.
- Add `compaction_threshold: Optional[int]` to ChatCompletionRequest
  (50k ge bound, 2M le bound). Thread through `routes/inference.py`
  -> `stream_chat_completion` -> `_stream_anthropic`.
- In `_stream_anthropic`, when threshold is set AND the model
  accepts compaction, attach `context_management.edits[{type:
  "compact_20260112", trigger:{type:"input_tokens", value:N}}]` to
  the outbound body. Sub-50k values are clamped up to 50k to keep
  the request well-formed.
- Refactor the anthropic-beta header builder to merge any combination
  of `code-execution-2025-08-25` + `compact-2026-01-12` flags into
  one header value. Unrelated betas added at the registry level still
  pass through.
- Add `test_anthropic_compaction.py` with 16 cases: gate matrix
  (every doc-listed model), correct body shape, threshold clamping,
  beta header merge with code execution, silent no-op on unsupported
  models, omitted-threshold pass-through.

Live verified end-to-end against the real Anthropic API:
`compact_20260112` accepted on Opus 4.7, response carries
`context_management.applied_edits` + `usage.iterations[]` as
documented. (The first WebFetch-summarised version of these docs
suggested `compact_20260120`; the actual API only accepts
`compact_20260112`, matching the beta-header date. Worth pinning
behind a test so a future doc update can't drift back.)

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

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

* Address review: drop ge=50_000 clamp + parse usage.iterations[]

Two reviewer follow-ups on the compaction PR:

1. Pydantic ge=50_000 on compaction_threshold was dead code.
   FastAPI rejected sub-50k threshold values with a 422 before the
   `max(int(...), _ANTHROPIC_COMPACTION_MIN)` clamp in
   _stream_anthropic could ever fire. Relaxed the floor to ge=1 so
   the in-helper clamp actually does its job; the schema comment
   now explains why this is intentional. Added a regression test
   that posts a value of 1 and 49_999 through the real request
   schema.

2. Anthropic publishes per-iteration token counts in
   `usage.iterations[]` whenever a fresh compaction has run, and
   the top-level input_tokens / output_tokens cover only the
   `message` iteration -- billing must add the compaction
   iterations on top. Aggregate compaction iteration tokens into
   `last_usage["compaction_input_tokens" / "compaction_output_tokens"]`
   so the cost surface (PR 5690) can read them without re-walking
   the array, and surface both figures in the closing stream
   summary log. Added two tests: one that pins the aggregation on a
   compacted turn and one that pins `None` when no fresh
   iterations land (so re-applied compaction blocks don't double-bill).

Sourcing: https://platform.claude.com/docs/en/build-with-claude/compaction

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

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

* Address review: round-trip Anthropic compaction blocks across turns

Codex P1: once context_management is enabled and Anthropic runs
server-side compaction mid-stream, the response carries a
`{type:"compaction", content:"<summary>"}` content block on the
assistant message. The translator only handled text_delta and
input_json_delta on content_block_delta, so the compaction block
was silently dropped. Worse, the request schema's ContentPart
discriminated Union didn't accept `type:"compaction"`, and
_build_external_messages didn't pass it through, so even a
hand-crafted assistant message carrying the block would 422 at
parse time. Net result: Anthropic re-compacted from scratch on
every subsequent turn, wasting input tokens and reasoning budget.

End-to-end backend wiring of the round-trip:

1. SSE translator. _stream_anthropic now tracks a `current_compaction`
   state slot. content_block_start with type=="compaction" seeds it
   (Anthropic may include the summary on the start event AND/OR
   stream it via text_delta events on the same block index --
   handle both). text_delta inside a compaction block routes into
   the compaction buffer instead of the user-visible content
   stream, since the summary is opaque internal state, not
   assistant prose. content_block_stop emits a `compaction_block`
   tool_event carrying the full summary so the chat-adapter can
   persist it. compaction_blocks_seen is surfaced in the closing
   summary log.

2. Pydantic schema. Added CompactionContentPart with Tag("compaction")
   on the ContentPart Union so requests carrying the block parse
   cleanly. Required `content` field with a docstring pointing at
   the Anthropic docs.

3. Message builder. _build_external_messages forwards compaction
   parts on both vision and non-vision paths; the per-provider
   stream helper decides whether to forward to the wire (Anthropic
   does; other providers ignore the part). When a non-vision route
   ends up with a single text part, collapse back to a string
   so providers that don't accept content arrays still get the
   expected shape.

4. _stream_anthropic outbound translator. {type:"compaction"} parts
   on an assistant message land on the wire verbatim. Empty/missing
   `content` is skipped so a malformed stored block can't 400
   Anthropic.

Tests added (5): stream emits compaction_block tool event with the
summary intact; user-visible content stream does NOT carry the
summary text; outbound body forwards compaction parts verbatim on
the next turn; Pydantic schema accepts the part; builder passes
it through on both vision and non-vision provider routes.

Frontend follow-up: the chat-adapter needs to persist the
compaction_block tool_event onto the stored assistant message so
turn N+1 includes it in payload.messages. Pinned in the PR
description.

Sourcing: https://platform.claude.com/docs/en/build-with-claude/compaction

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

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

* Address review: gate compaction-part passthrough to Anthropic only

Codex P1: my previous round-trip change preserved {type:"compaction"}
parts on every provider route in _build_external_messages. That
meant a chat history with prior compaction state silently leaked
the Anthropic-specific block to OpenAI/DeepSeek/Mistral/Gemini/
Kimi/OpenRouter on a provider switch, where generic
/chat/completions passthrough hands the unknown content type to
the upstream API and 400s the whole turn.

Added a `provider_type` kwarg to _build_external_messages and
gated the compaction forwarder on `provider_type == "anthropic"`.
Every other value (including the legacy None for callers that
don't pass it yet) strips the part. The Anthropic stream helper
still maps it to a native `compaction` block on the wire.

Threaded provider_type through from _proxy_to_external_provider's
call site.

Tests updated: vision + provider="anthropic" still forwards; six
non-anthropic providers strip the part; missing provider_type
strips defensively; non-vision + anthropic still forwards; non-vision
+ non-anthropic collapses back to a text string.

* [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>
2026-05-22 06:19:09 -07:00
Lee Jackson
61ed4cac51
Studio: persist chat history in backend storage (#5272)
* feat: Persist chat history in backend storage

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

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

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

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

* Address chat tombstone batching review

* fix: update desktop auth routes stub

* chat db settings storage

* chat db settings routes

* chat db settings client

* chat db settings store

* chat db settings wiring

* chat db history storage

* chat db settings migration

* chat db settings fallback

* chat db container metadata

* chat db legacy migration fixes

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

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

* chat ci auth background reads

* chat auth storage fixes

* chat migration final fixes

* chat export batch message lookup

* chat history review fixes

* chat prune sync fix

* chat settings hydration retry

* gate settings persistence

* Scope chat-history rows by subject; fix hijack, clear-confirm, hydrate race

Backend storage and routes:
- chat_threads / chat_messages / chat_settings carry a NOT NULL subject
  column with composite PRIMARY KEY (id, subject). Two authenticated
  identities can no longer see or wipe each other's data.
- Pre-existing rows on an existing studio.db migrate under sentinel
  subject __legacy_unscoped__ via rename + rebuild + copy; single-user
  installs see no behavior change.
- ON CONFLICT(id, subject) DO UPDATE ... WHERE chat_messages.thread_id =
  excluded.thread_id refuses cross-thread re-parenting via upsert.
  upsert_chat_message + sync_chat_messages now raise
  ChatMessageThreadMismatch which the routes map to HTTP 409.
- replace_thread_messages rejects body messages whose threadId does not
  match the URL thread (HTTP 400) instead of silently rewriting them.
- DELETE /api/chat requires ?confirm=true, returns row count, logs the
  subject and count.
- upsert_chat_settings_merge does read + deep-merge + write inside a
  single BEGIN IMMEDIATE so concurrent writers no longer drop each
  other's updates. The route delegates to this helper.
- New POST /api/chat/messages:batch returns {thread_id -> messages[]}
  for many threads in one HTTP call. Subject-scoped. Unknown ids return
  empty lists instead of 404 so the sidebar/search caller can rebuild
  atomically.

Frontend:
- chat-runtime-store: hydrate-failure catch sets settingsHydrated:true
  so a transient backend blip no longer permanently disables
  persistence. setParams bumps inferenceParamMutationVersions
  unconditionally so a slow hydration response cannot clobber a
  pre-hydrate user edit. saveSettingsPatch replaces the serial chain
  with a debounced pendingPatch + deep merge; flush on beforeunload.
- chat-history-storage: clearStoredChats returns ClearStoredChatsResult
  distinguishing backend / legacy / both outcomes.
  listStoredChatThreadsWithMessages uses the batched fetch (one HTTP
  call) instead of Promise.all per-thread; legacy Dexie fallback only
  fires when the batch result is empty.
- chat-api: batchListChatMessages with graceful 404 / 405 fallback to
  per-thread listChatMessages for older servers.
- chat-thread-tombstones: store {id, deletedAt} tuples with 90-day GC
  and a 5000-entry cap so localStorage stays bounded. Back-compat reads
  pre-fix plain strings. Adds removeChatThreadTombstones (rollback) and
  clearAllChatThreadTombstones (post-legacy-purge clean-up).
- use-chat-sidebar-items: deleteChatItem tombstones synchronously
  BEFORE the backend round-trip and rolls back on failure (restores
  pre-PR optimistic UX). 300 ms trailing debounce on
  CHAT_HISTORY_UPDATED_EVENT plus requestSeq guard so stream-time event
  bursts produce at most one fetch per quiet window.

Tests:
- studio/backend/tests/pr5272_sim/ adds 64 regression tests covering
  schema migration from pre-fix shape, subject scoping, cross-thread
  hijack, bulk-replace mismatch, clear-confirm, concurrent settings,
  unicode + 2MB content + SQL-injection-safe binding, chunking
  boundary at 900 and 901 ids, batched endpoint (multi-subject + 1200
  ids + per-thread order), and grep contracts for the frontend patches.
  test_chat_history_storage.py updated to pass subject.

Verified locally on Linux + macOS + Windows GitHub Actions runners
(staging fork): 64 pass + 2 from the PR's own backend test on all
three OSes.

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

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

* Drop subject scoping and clear-confirm gate (Studio is single-user)

Per maintainer feedback: subject scoping, cross-thread message hijack
guard, and DELETE /api/chat ?confirm=true gate are unnecessary because
Studio is intentionally single-user (the client already shows a confirm
dialog before clear-all).

This commit reverts those backend changes and keeps only the
non-multi-user pieces from the earlier fix commit:

- studio_db.py: restored to pre-fix shape; adds upsert_chat_settings_merge
  which does atomic read + deep-merge + write under BEGIN IMMEDIATE so
  two concurrent slider drags cannot drop one another's updates.
- routes/chat_history.py: restored; put_settings now calls the atomic
  merge instead of doing the read-merge-write across three separate
  connections. Adds POST /api/chat/messages:batch to collapse the
  sidebar/search rebuild from N round-trips to 1.
- frontend/api/chat-api.ts: align batchListChatMessages request and
  response keys with the backend (threadIds / messagesByThreadId).
- tests/test_chat_history_storage.py: add atomic-merge concurrency test,
  deep-merge nested-key test, and 901-id chunking-boundary test.
- Drop the pr5272_sim test directory (those tests covered the reverted
  subject-scoping/hijack/confirm behavior).

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

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

* Fix sidebar delete crash, keepalive on settings beforeunload flush, search rebuild race

Two correctness bugs and one perf race surfaced by a fresh code review of
the prior fix commit:

- chat-api.ts: notifyChatHistoryUpdated was declared as a non-exported
  function, but use-chat-sidebar-items.ts imports it. The import would
  fail tsc with TS2305 and at runtime the optimistic-delete and
  delete-failure rollback paths would both throw.
- chat-runtime-store.ts + chat-settings-api.ts + chat-settings-storage.ts:
  the beforeunload settings flush is now actually keepalive. Without it
  the browser cancels the in-flight PUT on tab close, so the last slider
  drag is silently dropped (which is exactly the case the
  debounce+beforeunload combination was meant to protect against).
- use-chat-search-index.ts: rebuilds now coalesce with a 300ms trailing
  debounce and discard out-of-order responses via a requestSeq guard.
  Matches the sibling pattern in use-chat-sidebar-items.ts so two rapid
  CHAT_HISTORY_UPDATED_EVENTs (run-start + run-end save during a turn)
  cannot land with stale data winning.
- chat-thread-tombstones.ts: drop dead clearAllChatThreadTombstones with
  no call sites; Dexie is never wiped so the function has no use.

* fix(studio): protect chat persistence writes

* fix(studio): align chat history clear semantics

* fix(studio): show partial chat clear feedback

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

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

* fix(studio): preserve chat persistence fallbacks

* fix(studio): harden chat thread persistence checks

* Preserve chat message timestamps

* Gate chat stream on history save

* Make chat thread backfill best effort

* Avoid chat message 404 probe

* Tighten chat legacy fallbacks

* chat: server-side ledger so legacy Dexie import is recoverable

The boolean localStorage sentinel
(unsloth_chat_legacy_imported_to_studio_db) made importLegacyChatsIfNeeded
non-recoverable: deleting studio.db while the browser keeps the flag
silently hides every legacy Dexie thread from the sidebar (verified by
the 3-GPU validation probe; matches the third review comment on PR
#5272). Same trap fires for browser-profile sync to a fresh machine
and any other path that wipes studio.db while keeping IndexedDB.

Source of truth moves into studio.db itself via a new
chat_legacy_import_log table keyed by legacy thread id. The ledger
disappears together with studio.db, so the next launch re-runs the
import from whatever Dexie still holds. localStorage stays as a
per-session perf hint only.

Performance, all bounded by the three new fast-paths before any
backend work:

  A) localStorage hint says "imported earlier in this session" -- 0
     network, ~0 ms. Covers the warm sidebar mount.

  B) indexedDB.databases() reports no "unsloth-chat" DB -- 0 network,
     ~1 ms. Covers every new user who never had the old browser-only
     Studio (the common case after launch).

  C) db.threads.count() + db.messages.count() are both 0 -- 0 network,
     ~5 ms. Covers returning users who migrated long ago and Dexie was
     never repopulated.

Only when all three miss does the code talk to the backend
(GET /api/chat/import-ledger -> diff vs Dexie -> existing import path
-> POST /api/chat/import-ledger to record what was just imported).
Per-thread tracking is enough because Dexie is read-only after this
PR; a thread's message set does not grow.

Backend deployments that predate the import-ledger routes are
handled transparently: the client treats 404/405 as an empty ledger
and re-runs the (idempotent via UPSERT) import on next launch.

Changes:
- storage/studio_db.py: new chat_legacy_import_log table (WITHOUT
  ROWID, PK on legacy_thread_id) + list_chat_legacy_import_log() +
  record_chat_legacy_import_log() (idempotent batch UPSERT).
- routes/chat_history.py: GET + POST /api/chat/import-ledger with the
  obvious request/response models.
- frontend api/chat-api.ts: listChatImportLedger() (returns a Set for
  O(1) diff) + recordChatImportLedger(), both with 404/405 fallback.
- frontend utils/chat-history-storage.ts: importLegacyChatsIfNeeded
  gains three fast-paths, ledger fetch on the slow path, and writes
  the ledger after a successful import. The localStorage helper is
  unchanged on the surface; it just stops being authoritative.
- tests: 5 new test_legacy_import_log_* cases (empty default, record
  + list round-trip, idempotency, input dedup, empty/null ignore).
  All 9 pre-existing tests still pass.

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

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

* Make the legacy-import recovery actually recoverable

The previous commit added a server-side ledger to make Dexie -> studio.db
import recoverable after a studio.db wipe, but the localStorage perf hint
still short-circuited the import gate before the ledger was ever consulted.
After a wipe, the hint stayed "true" and the bulk re-import never ran -- the
ledger sat empty and only the per-thread lazy materialize-on-continue path
restored data.

Changes:

- Remove the localStorage short-circuit from importLegacyChatsIfNeeded so
  the ledger is checked on every fresh tab. legacyChatImportPromise keeps
  the per-session cache; the hint now only matters for the listing paths.
- Batch the slow path: one db.messages.where().anyOf().toArray() and one
  batchListChatMessages() instead of 2N round-trips. At 1k threads this
  drops a multi-second blocking import to a single request pair.
- recordChatImportLedger returns {accepted, inserted, supported}. The
  localStorage hint is only flipped when supported is true, so old
  backends (404 / 405 / 501) no longer permanently poison recovery.
- Ledger backfill: threads already present in chat_threads but missing
  from the ledger now get added too, so old-FE-then-new-FE deployments
  don't redo the diff every launch.
- Backend response field renamed recorded -> {accepted, inserted}.
  accepted is the deduped non-empty input count; inserted is the rows
  actually new (via INSERT ... RETURNING). Bounded by Field(max_length=
  10_000) on the request payload.
- Storage helpers renamed: chat_legacy_import_log -> chat_legacy_imports,
  record_* -> upsert_* to match the existing noun/verb conventions.
- DEXIE_DB_NAME exported from db.ts; duplicate constant in
  chat-history-storage.ts removed.
- 3 new route-level tests for /api/chat/import-ledger covering the
  round-trip, the (accepted, inserted) split, and the 10k payload cap.

All 18 chat-history tests pass.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: shine1i <wasimysdev@gmail.com>
Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-05-22 06:18:05 -07:00
Daniel Han
a2d2b7866f
Studio: wire Anthropic web_fetch server-side tool (#5671)
* Studio: wire Anthropic web_fetch server-side tool

Studio's Anthropic passthrough only forwarded web_search and
code_execution when enabled_tools was set. Asking Claude through Studio
to fetch a URL produced no fetch (the tool was not in the outbound
tools array), so users had to fall back to web_search even when they
already had the exact URL they wanted.

This change opts in web_fetch_20250910 when enabled_tools contains
"web_fetch". The new tool entry is appended alongside any existing
web_search / code_execution entries:

  {"type": "web_fetch_20250910", "name": "web_fetch", "max_uses": 5}

No anthropic-beta header is required (web_fetch is GA); the existing
code-execution-2025-08-25 flag continues to merge cleanly when both
tools are enabled in the same turn.

SSE translation mirrors the web_search path. A `server_tool_use` block
with name="web_fetch" emits a `tool_start` _toolEvent carrying the
URL the model asked to fetch; the matching `web_fetch_tool_result`
block emits a `tool_end` _toolEvent whose result string follows the
Title / URL / Snippet shape parseSourcesFromResult on the frontend
already expects, so the source pill renders identically. Error blocks
(`web_fetch_tool_error`) are surfaced as "Error: <error_code>" matching
the code_execution error path.

The final "Anthropic stream complete" log line picks up web_fetch_
requested / web_fetch_invocations / web_fetch_urls so support reports
of "the model did not fetch anything" can be triaged from the log.

Verified end to end against claude-haiku-4-5 with
`enabled_tools=["web_fetch"]`: the model emitted tool_start with
url=https://example.com and tool_end with the page Title + URL +
Snippet, plus the assistant message correctly read back "Example
Domain" as the title.

Tests:
- 5 new unit tests in test_anthropic_web_fetch.py covering tool
  registration, the combined web_search + web_fetch + code_execution
  request body, the pill-off case, and SSE translation for both
  success and error paths.
- All 242 existing Anthropic + OpenAI provider tests still pass.

The enabled_tools field description in models/inference.py is updated
so OpenAPI consumers see the new option.

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

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

* web_fetch: title fallback to URL, log parse failures, drop dead checks

Three review nits on the previous commit:

1. `_format_web_fetch_result` left `title` empty when Anthropic omitted
   `document.title`. The frontend `parseSourcesFromResult` only emits
   a source pill when both `Title:` and `URL:` lines are present, so
   fetches against pages without an HTML title tag silently lost
   their citation in the UI. Fall back to `title = title or url`,
   matching the web_search formatter.

2. The broad `except Exception` around `json.loads(buffer)` for the
   web_fetch input swallowed the failure with no trace. Log at debug
   so a malformed partial_json buffer can be triaged from the server
   log without changing behavior.

3. `inner` was already sanitised to a dict at the matching
   content_block_start and `_format_web_fetch_result` always returns
   a non-empty string (defaulting to "(fetch complete)"), so the
   `isinstance(inner, dict) else {}` guard and the
   `result_text or "(fetch complete)"` fallback at the emit site
   were dead code. Removed.

Added a test exercising the titleless path so the fallback stays
covered.

* chat-adapter: emit source pills for web_fetch tool calls

`parseSourcesFromResult` was only wired up for tool calls where
`toolName === "web_search"`, so the Title / URL / Snippet block the
backend formatter emits for `web_fetch_tool_result` never reached the
source-pill renderer. Users saw the raw tool result in the tool card
but the dedicated source-pill row at the message tail stayed empty.

Both web_search and web_fetch ship the same text shape today, so the
fix is to broaden the gate.

* Address review: wire web_fetch from Search pill + fix pause_turn truncation

Two reviewer follow-ups on the Anthropic web_fetch PR:

1. The backend tool wiring landed but the frontend chat-adapter
   never put `web_fetch` in `enabled_tools`, so toggling the Search
   pill only ever attached `web_search` -- web_fetch was unreachable
   from the UI. Added providerSupportsBuiltinWebFetch() (Anthropic
   today) and paired the entry with the existing Search pill, since
   the canonical workflow is "search returns URLs, fetch reads
   them" and there is no separate UI toggle yet.

2. `pause_turn` from Anthropic's stop_reason vocabulary fell through
   the finish_reason map's "stop" default, which the OpenAI-format
   client renders as end-of-message and truncates the answer. Per
   the docs pause_turn means "Claude paused a long server-tool
   turn (web_search / web_fetch) and will resume". Mapped to None
   and skipped the chunk emission so the SSE stream still ends with
   [DONE] on message_stop but no terminal finish_reason lands on
   the client. While there: added explicit mappings for `tool_use`
   (-> tool_calls) and `refusal` (-> content_filter) which were
   also falling through to "stop".

Tests added: pause_turn emits no finish_reason, end_turn still
emits "stop", refusal maps to "content_filter".

Sourcing: https://platform.claude.com/docs/en/api/messages#response-stop-reason

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-22 06:03:48 -07:00
Daniel Han
2201fd687b
Studio: per-session cost calculator + /api/providers/pricing endpoint (#5690)
* Studio: per-session cost calculator + /api/providers/pricing endpoint

Neither the Anthropic Messages API nor the OpenAI Responses API
reports a `cost` field on the response. Both expose detailed token
counts (input, output, cache hits, server-tool invocations); pricing
multipliers live in the provider docs. The frontend's "cost so far"
display was impossible without scraping the server log.

Land the math + a snapshot endpoint so the cost calculator can run
client-side from the existing usage chunk plumbing. The actual UI
hookup belongs in a frontend follow-up (and is gated on PR #5670's
usage-chunk emission landing so the frontend sees the usage block
in the first place).

Changes:

- New `core/inference/pricing.py` with:
  - Per-MTok base pricing tables for every active Anthropic and
    gpt-5.x family member. Dated snapshots inherit the canonical-id
    price via prefix match so future snapshots cost the same as the
    canonical id until pricing changes.
  - Shared multipliers for Anthropic cache writes (5m: 1.25x, 1h: 2x)
    and reads (0.1x); OpenAI cache reads (0.1x); Anthropic server
    tool surcharges ($10 / 1k web_search, $0.05 / hour code_exec
    beyond the 50-hour daily free tier).
  - `calculate_cost(provider, model, usage)` returns a per-turn USD
    breakdown plus billable token counts, with priced=False for
    unknown models so the UI can still render token counts.
  - `pricing_snapshot()` returns the whole table for the frontend
    so it doesn't re-implement the multipliers.
- New `GET /api/providers/pricing` returning the snapshot, scoped
  behind the existing auth dependency.
- New `backend/tests/test_pricing.py` with 12 cases pinning the
  math against documented values: base input/output multiplication,
  5m / 1h / read multipliers, default-to-5m fallback when the
  breakdown is absent, web_search per-1k pricing, code_execution
  per-hour pricing, dated-snapshot fallback, OpenAI cache-read
  discount accounting (cached tokens subtracted from full-price
  bucket and re-billed at 0.1x), unknown model graceful-degrade,
  and the snapshot endpoint shape.

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

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

* Studio: verified OpenAI pricing + fix billable input double-count

Address the cost-calculator review:

- OpenAI prices were 2-6x under the actual published rates.
  Cross-checked the live developers.openai.com/api/docs/pricing page
  and replaced every entry. gpt-5.5 is 5/30, gpt-5.5-pro is 30/180,
  gpt-5.4 is 2.5/15, gpt-5.4-mini 0.75/4.5, gpt-5.4-nano 0.20/1.25,
  gpt-5.3-codex 1.75/14. Added chat-latest alias to the canonical
  chat-snapshot rate. Dropped o3 / o4 / gpt-4.5 rows that are no
  longer listed on the page; calculator returns priced=False instead
  of silently billing at zero.

- billable_input_tokens was double-counting cached tokens for
  OpenAI. Anthropic excludes cache_* buckets from input_tokens so
  we add them; OpenAI folds cache_read_input_tokens into
  input_tokens already, so the tooltip read 1.8M for a 1.0M bill.
  Branched the math by provider and added a regression test.

Sourcing notes in the module docstring updated.

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

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

* Address review: canonical 4.5 ids, long-context tier, OpenAI tool fees

Three Codex P1 follow-ups on the cost calculator:

1. Canonical Anthropic 4.5 ids missing from ANTHROPIC_PRICING.
   claude-opus-4-5 / claude-sonnet-4-5 / claude-haiku-4-5 (no date
   suffix) are the ids used by backend defaults
   (PROVIDER_REGISTRY['anthropic'].default_models), but the table
   only had the dated forms. _lookup's prefix fallback doesn't help
   because the canonical id is SHORTER than the dated key, so
   str.startswith goes the wrong way and the calculator returned
   priced=False + zero cost. Added the canonical aliases for
   opus-4-5, sonnet-4-5, haiku-4-5, and opus-4-1.

2. OpenAI long-context tier. gpt-5.5 and gpt-5.4 cross over at
   272k input tokens to a 2x input / 1.5x output rate (gpt-5.5:
   $5/$30 -> $10/$45; gpt-5.4: $2.50/$15 -> $5/$22.50). Turns past
   the threshold were systematically undercounted at headline
   rates. Added long_context_threshold / long_context_input_per_mtok /
   long_context_output_per_mtok columns and a tier-selection step
   in calculate_cost; model_priced gains a "(long-context >272000)"
   suffix when the higher tier applies so the tooltip can show
   which rate was used. gpt-5.5-pro / gpt-5.4-pro / mini / nano /
   codex have no published long-context tier today, so they keep a
   single rate.

3. OpenAI server-tool surcharges. web_search is $10/1000 calls and
   the hosted shell container is $0.03 per 20-minute session on the
   default 1g tier (~$0.09/hr). server_tools_usd was previously
   stuck at 0.0 for OpenAI even when web_search and shell tools
   fired, so sessions with tool use understated cost. Added
   OPENAI_WEB_SEARCH_USD_PER_1K and OPENAI_CONTAINER_USD_PER_HOUR
   constants plus a parallel of the Anthropic surcharge block that
   reads counts from usage["openai_tool_use"]. The SSE translator
   wires the counts in a follow-up commit; the calculator is now
   ready for them. pricing_snapshot also exposes both constants so
   the frontend tooltip can render the per-call rate.

Existing tests updated to stay in the short-context tier where they
were testing base rates; new tests pin canonical 4.5 lookups,
long-context crossover on gpt-5.5/gpt-5.4, the absence of crossover
on mini/nano/codex, and OpenAI tool surcharges (web_search,
container hours, combined total).

* [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>
2026-05-22 06:03:43 -07:00
Daniel Han
5b41872e8b
Studio: wire OpenAI image_generation tool (#5688)
* Studio: wire OpenAI image_generation tool

OpenAI's Responses API exposes server-side image generation as a
tool entry (`{type: "image_generation"}`); the result comes back as
an `image_generation_call` output item with the base64 image on
`result`, the actual prompt used on `revised_prompt`, plus `size`,
`quality`, `output_format`, `background`. The model decides when to
call the tool based on the user's request; rendering uses one of
the gpt-image-* backbones server-side.

Available on every gpt-5.x family member plus gpt-4.1, gpt-4o, o3,
o4-mini per the docs.

Changes:

- Append `{type:"image_generation"}` to the Responses request tools
  array when `enabled_tools` carries `image_generation` AND the base
  URL points at cloud OpenAI. Non-cloud bases (ollama, llama.cpp,
  "custom" presets that collapse to provider="openai") silently drop
  the tool to avoid 400s.
- Mirror the same logic in `_build_body` (the post-expiry retry
  builder) so retries carry the same tool set as the original
  attempt.
- Handle `image_generation_call` items in
  `response.output_item.done`: emit `tool_start` with
  `arguments:{kind:"image", prompt:<revised_prompt>}` and `tool_end`
  with `image_b64`, `image_mime`, `size`, `quality`, `background`
  so the chat adapter can render an inline preview. Image bytes go
  on the tool_end chunk; no extra fields on the chat-completions
  envelope so the OpenAI SDK shape stays clean.
- Add `import time` (used for synthesised tool_call_id fallback).
- Add `test_openai_image_generation.py` with 5 cases: tool entry on
  cloud OpenAI, combined with web_search + code_execution
  (verifies all three coexist), non-cloud drop, omitted pill leaves
  body untouched, output item translation produces the expected
  tool_start + tool_end chunks.

Live verified end-to-end: `gpt-5.4-mini` with `image_generation`
tool returned an `image_generation_call` carrying ~1MB of base64
PNG plus the gpt-image backbone's revised prompt.

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

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

* Use time.time_ns() for synthesised image_generation tool_call_id

Gemini medium on PR #5688: `int(time.time() * 1000)` has 1ms
resolution; two image generations resolving in the same millisecond
would collide on the synthesised id. Bump to nanoseconds.

(In practice the upstream `image_generation_call` item always carries
its own `id`; the synthesised fallback only fires when OpenAI omits
it -- rare, but cheap to harden.)

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-22 06:03:38 -07:00
Daniel Han
b8dde0a835
Studio: support Anthropic 1h cache TTL via prompt_cache_ttl (#5685)
* Studio: support Anthropic 1h cache TTL via prompt_cache_ttl field

Anthropic exposes two ephemeral cache pools per request: the default
5-minute pool, and a 1-hour pool selected by attaching `ttl:"1h"` to
the `cache_control` marker. 1h writes are billed at 2x base input vs
1.25x for 5m, but reads stay at 0.1x for both, so a single extra read
landing more than 5 minutes after the write pays off the premium.

Studio hardcoded the 5m pool via `cache_control: {type:"ephemeral"}`
on both breakpoints. For chats with multi-minute idle gaps (people
juggling tabs, long-running tool calls between turns), the cache
expires before the next turn and every read becomes a cache_creation,
not a cache_read -- exactly the case where the 1h pool wins.

Changes:

- Add `prompt_cache_ttl: Optional[Literal["5m", "1h"]]` to
  ChatCompletionRequest. Default (None) preserves today's 5m behavior.
- Thread through `routes/inference.py` ->
  `stream_chat_completion` -> `_stream_anthropic`.
- Build a shared `cache_marker` dict in `_stream_anthropic`; attach
  `ttl` only when the request asks for one of the two valid values.
  Unknown TTL strings are silently dropped to avoid sending malformed
  markers (the upstream API would 400).
- Apply the same marker to both existing breakpoints (system block at
  line 1175 and the latest-message tail at line 1198 / 1213) so the
  pool selection is consistent across the whole prefix.
- Add `test_anthropic_cache_ttl.py` with 11 parametrized cases
  pinning the outbound body shape: omitted -> default marker;
  explicit `5m`/`1h` -> ttl field set; unknown values dropped;
  caching off -> no markers at all.

Verified upstream that `cache_control: {type:"ephemeral", ttl:"1h"}`
is accepted by the Anthropic API today; no beta header required.

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

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

* Relax prompt_cache_ttl to Optional[str] (Codex P1)

Declaring `prompt_cache_ttl` as `Optional[Literal["5m", "1h"]]` made
FastAPI/Pydantic 422 the request before _stream_anthropic could even
see the field. The whole point of the downstream drop-unknown-values
behaviour was to keep a stale frontend from crashing the request;
the strict Literal at the request layer defeated that.

Loosen the schema to Optional[str]; the existing in-helper guard
already restricts forwarded values to {"5m", "1h"} (everything else
is silently dropped). Test suite stays unchanged -- the bogus-value
cases in test_anthropic_cache_ttl.py already pass arbitrary strings
through and assert they are dropped before the wire.

* Address review: confirm extended-cache-ttl beta header is GA

Reviewer asked whether the 1h cache TTL still requires the
`extended-cache-ttl-2025-04-11` anthropic-beta header. Investigated:

- Live-tested api.anthropic.com on claude-opus-4-7 (2026-05-22)
  with cache_control={type:"ephemeral", ttl:"1h"} and NO beta
  header. Got status 200 and ephemeral_1h_input_tokens populated
  on the create turn, plus cache_read_input_tokens populated on
  the reuse turn.
- Cross-checked the current prompt-caching docs: no mention of
  any beta header on the 1h TTL path.

Conclusion: the gate has been promoted to GA. The code already
does not send the beta header (the cache_marker dict only carries
`type`/`ttl`), so no wire change is needed. Pinned the contract
with two regression tests that assert the header is NOT on the
outbound request, and added a docstring note explaining the
investigation outcome so a future reader does not re-add it.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-22 06:03:32 -07:00
Daniel Han
f399e3b9d1
Studio: per-model Anthropic server-side tool versions (#5679)
* Studio: per-model Anthropic server-side tool versions

Anthropic ships date-pinned tool versions per model family. Studio
currently hard-codes `web_search_20250305`, `web_fetch_20250910`, and
`code_execution_20250825` for every model, which means Opus 4.6/4.7,
Sonnet 4.6 and the Opus/Sonnet 4.5 family never get the newer
`_20260209` / `_20260120` variants. Those newer variants add dynamic
filtering (Claude writes code to rank/filter web results before they
enter context) and REPL state persistence + programmatic tool calling
inside the sandbox, which is what the user-facing pills are supposed
to expose.

Hardcoding the legacy versions also breaks if a future model family
drops the legacy types: the request 400s instead of falling back.

Changes:

- Add `_anthropic_web_search_version`, `_anthropic_web_fetch_version`,
  `_anthropic_code_execution_version` helpers that pick the newest
  variant the model accepts and fall back to the GA versions for
  everything else.
- Add `_ANTHROPIC_CODE_EXECUTION_BETA` constant since the beta header
  (`code-execution-2025-08-25`) is shared across both code-execution
  date variants per the upstream docs.
- Wire the helpers into `_stream_anthropic` so the outbound body
  carries the right pinned version per request.
- Add parametrized dispatch tests in
  `test_anthropic_tool_versions.py` covering Opus 4.7/4.6/4.5,
  Sonnet 4.6/4.5, Haiku 4.5, Opus 4.1/4.0, Sonnet 4.0, 3.5 Sonnet,
  plus streaming integration tests that verify the outbound body
  uses the right versions on Opus 4.7 (new web_search + new
  code_execution), Haiku 4.5 (legacy both), and Sonnet 4.5 (legacy
  web_search + new code_execution).
- Update existing `test_anthropic_code_execution.py` cases that
  pinned the old version on Opus 4.7 to expect the new ones.

Verified end-to-end against the live Anthropic API: Opus 4.7 with
both pills enabled accepts the newer-pinned tools without a 400, and
Haiku 4.5 still works on the legacy fallback path.

* [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>
2026-05-22 06:03:27 -07:00
Daniel Han
ba9405b908
Studio: surface prompt-cache token counts in /v1/chat/completions usage chunk (#5670)
* Studio: surface prompt-cache token counts in /v1/chat/completions usage chunk

Studio's Anthropic and OpenAI Responses proxies already capture
cache_creation_input_tokens, cache_read_input_tokens (Anthropic) and
input_tokens_details.cached_tokens (OpenAI), but they were only written
to the structlog stream. Browser and SDK clients had no way to compute
"how many tokens hit the prompt cache" without scraping the server log,
so the chat UI could not show users how much money the cache was
saving on each turn.

This change emits one extra OpenAI include_usage-style chunk
(choices: [] with a populated usage block) just before the existing
[DONE] for Anthropic and after the final finish_reason chunk for
OpenAI Responses (both response.completed and response.incomplete).
The chunk shape:

  usage.prompt_tokens_details.cached_tokens
      normalised cache-read count, present for both providers.
  usage.cache_creation_input_tokens
      Anthropic-only; tokens billed at the cache-write premium.
  usage.cache_read_input_tokens
      Anthropic-only; same value as cached_tokens, kept for callers
      that already key off the native Anthropic name.

Smoke verified end to end against a live Studio (claude-haiku-4-5
and gpt-4o-mini) plus 7 new unit tests on the helper and the two
streaming paths.

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

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

* Anthropic: include cache buckets in prompt_tokens / total_tokens

Anthropic's `input_tokens` field excludes the cache buckets -- the
real prompt size is `input_tokens + cache_creation_input_tokens +
cache_read_input_tokens`. Previously the new usage chunk reported
only `input_tokens` as `prompt_tokens`, which heavily undercounted
cache-hit turns (e.g. an 18.9k-token cache_read turn looked like an
8-token prompt) and broke any downstream context / cost display fed
by `prompt_tokens` or `total_tokens`.

Fix `_build_usage_chunk` to sum all three input buckets for the
Anthropic provider while keeping the OpenAI Responses path unchanged
(OpenAI already folds cached tokens into `input_tokens`). The native
`cache_creation_input_tokens` / `cache_read_input_tokens` keys and
`prompt_tokens_details.cached_tokens` mirror are still emitted, so
clients keep full visibility of the cache split.

Tests updated to assert the summed shape.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-22 06:02:52 -07:00
Daniel Han
2e1d0e2f19
studio: settle GPU VRAM after killing llama-server before the next reload (#5693)
* studio: settle GPU VRAM after killing llama-server before the next reload

The NVIDIA driver reclaims a dead process's CUDA allocations
asynchronously after the kernel reaps the PID -- typically tens to
hundreds of milliseconds. Sampling `_get_gpu_free_memory` in that
window reads artificially low, which propagates into `_select_gpus`
/ `_fit_context_to_vram` and flips the layer-split toward `--fit on`
with more CPU-offloaded layers than steady-state would have required.
On a tight VRAM card the resulting mmap thrash + OOM matches the
Apply-reload kill path that bare-shell launches with the same flags
never hit (continues the lineage of #5161 / #5401 / #5427).

Adds `LlamaCppBackend._wait_for_vram_settle`: bounded poll of
`_get_gpu_free_memory` that returns as soon as two consecutive
samples agree per-GPU within `max(256 MiB, 2% of larger sample)`,
or `max_wait` (default 2 s) wall-clock elapses with probe time
included in the bound. Records `_last_kill_monotonic` inside
`_kill_process`'s `finally` block so the wait engages on both
in-process `load_model -> _kill_process -> load` and the frontend
chat-settings Apply path (`/unload` then `/load`). The call site
runs OUTSIDE the broad `self._lock` so concurrent `/unload`,
`/cancel`, `/status` are not blocked during the wait.

Short-circuits at zero cost on cold start (no kill recorded), stale
kill (older than 15 s, driver has already settled), CPU-only host
(probe returns empty), and probe exceptions (nvidia-smi gone away).

11 new unit tests in `test_llama_cpp_wait_for_vram_settle.py` cover:
cold-start zero cost, stale-kill skip, slow-probe deadline bound,
GPU index-set change, per-GPU stability with one draining card, the
2 % adaptive tolerance, _kill_process timestamp recording on real
kill vs no-op, and an `inspect.getsource` contract that pins the
call site to outside the Phase 3 lock and uses `_last_kill_monotonic`
so a future refactor can't silently regress any of these properties.

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

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

---------

Co-authored-by: Michael Han <michaelhan2050@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-22 05:50:39 -07:00
Daniel Han
7482685757
studio: unblock /load event loop on detect_audio_type (#5642, #5635) (#5669)
* studio: unblock /load event loop on detect_audio_type (#5642, #5635)

studio/backend/routes/inference.py wraps llama_backend.detect_audio_type
in await asyncio.to_thread() so its chain of sequential sync
httpx.Client.post() probes (/tokenize and /detokenize, 10 s timeout
each) runs on the threadpool instead of blocking the FastAPI event
loop. Without this wrap, /api/inference/load-progress polling and any
other in-flight HTTP request stalls for up to ~80 s while
detect_audio_type runs, which is exactly the "llama-server logs say
ready, Studio UI never finishes loading" symptom in #5642 (Win10) and
#5635 (Win11). The matching init_audio_codec call on the next branch
was already wrapped; this just brings detect_audio_type to parity.

Add a CPU-only spoof-based test suite under tests/studio/load_freeze/:
  - llama_server_shim.py: stdlib http.server that answers /health,
    /props, /tokenize, /detokenize, /completion with per-request
    delay knobs.
  - test_load_orchestrator.py:
      * test_buggy_route_blocks_event_loop -- behavioural canary:
        with a sync detect_audio_type call, concurrent /health
        requests stall for >= one tokenize delay (proves the bug
        class, runs from worker threads against a real uvicorn).
      * test_fixed_route_keeps_event_loop_responsive -- with the
        to_thread wrap, concurrent /health latency stays under 250 ms.
      * test_routes_inference_wraps_detect_audio_type_in_to_thread --
        static guard so the fix cannot regress silently.
      * test_fast_path_load_completes_quickly -- regression budget
        for post-_wait_for_health work.

Add .github/workflows/studio-load-orchestrator-ci.yml. CPU-only,
no torch, no real llama.cpp binary, no GPU. Cross-OS proof
(ubuntu-latest / macos-14 / windows-latest, 4 passed in 7-10 s each)
ran green on danielhanchen/unsloth-staging-2#136 before landing here.

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

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

* studio: expand load-orchestrator suite to 22 tests (failure modes, stress, drift)

Replace the 4-test smoke with a comprehensive 22-test simulation
covering every failure mode of the /load -> detect_audio_type path:

  1. Behavioural canary (2)        - sync vs to_thread under slow shim
  2. Functional equivalence (5)    - sync == to_thread for each codec
                                     branch (None / snac / csm / whisper
                                     / bicodec)
  3. Failure modes (5)             - shim returns 500, malformed JSON,
                                     connection reset, unreachable port,
                                     backend not loaded
  4. Concurrency / stress (2)      - 50 concurrent /probe; 100-burst
                                     /health during slow /probe
  5. Drift / regression guards (3) - wrap on production source, neighbour
                                     init_audio_codec still wrapped, no
                                     bare detect_audio_type() in any
                                     async route
  6. Timing budgets (2)            - fast-path under 2s; 5 sequential
                                     /probes under 10s
  7. Browser-compat (2)            - Content-Type + JSON.parse round-trip
                                     + response shape stable sync vs fix
  8. Cancellation (1)              - client disconnect mid-probe; server
                                     keeps serving /health afterwards

Extended llama_server_shim with knobs for HTTP-500, malformed-JSON,
connection-reset, and tok_response_map / detok_map so we can
synthesise the exact request/response shape that triggers each codec
match. No new dependencies, still CPU-only and stdlib-driven.

Cross-OS validation on danielhanchen/unsloth-staging-2#136:
  - ubuntu-latest:  22 passed in 19.59s
  - macos-14:       22 passed in 22.07s
  - windows-latest: 22 passed in 38.79s
Cross-Python on Linux (3.10 / 3.11 / 3.12 / 3.13 x pinned-floor /
latest deps, 8 uv venvs): 176/176 passed.

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

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

* studio: move audio detect/codec init inside load_model lock; relax small-quant CI

Follow-up to #5642 fix that addresses two distinct concerns raised by
the gemini-code-assist review on PR #5669:

1. Race condition (medium-priority comment on routes/inference.py:869)

   The original fix wrapped llama_backend.detect_audio_type in
   asyncio.to_thread. That unblocks the FastAPI event loop but opens
   a race window where a concurrent /api/inference/load can acquire
   _serial_load_lock, kill the live llama-server, and start a new
   one while the first request's detect_audio_type thread is still
   probing the (now-dead) port -- the route then writes stale
   _is_audio / _audio_type onto the shared backend instance.

   Fix: move detect_audio_type + init_audio_codec INSIDE
   LlamaCppBackend.load_model, immediately before the function
   returns True. Both calls happen while self._serial_load_lock is
   held, so the entire load sequence (spawn, wait health, detect
   audio, init codec, return) is atomic. routes/inference.py now
   just reads the cached _audio_type / _is_audio attributes.

   This is the shape the gemini reviewer recommended, and it also
   simplifies the route -- no more asyncio.to_thread wrap, no more
   conditional init_audio_codec call. The route layer keeps its
   non-inference responsibilities (_native_display_label /
   _native_grant_backed assignments) since those depend on
   route-local arguments.

2. Hardcoded local file path in test shim (gemini's other comment)

   FakeLlamaServer's default model_path was a developer-specific
   Windows cache path. Replaced with an OS-portable placeholder.
   The value is cosmetic-only -- only used in the synthesised stdout
   template's "loading model" line, which the production code we
   drive from the tests does not parse.

3. Existing CI flake on studio-inference-smoke.yml (generalised fix)

   Studio GGUF CI has been red on main and 5+ unrelated PRs all
   day. Root cause: small-quant Qwen3.5-2B drifts in two places.
   (a) The python tool spits back "55,888" instead of "56088"
   even though the tool itself returned the correct value. (b) The
   OpenAI / Anthropic determinism check sees occasional non-byte-
   identical responses at temperature=0.0 across runs due to KV
   cache / speculative-decoding non-determinism. Both are model
   output drift, not Studio regressions.

   Generalised fix: match the Windows variant's already-lenient
   WARN-when-tool-ran-but-model-drifted pattern. SSE-stream-empty
   stays a hard FAIL (real plumbing failure); a non-empty stream
   with the wrong numeric content becomes a WARN. Determinism
   check similarly demotes "trailing whitespace OK but content
   diverged" to a WARN; the harder grounding assertions on
   later turns (paris present somewhere, turn-1 contains '1')
   remain strict and continue to catch real regressions.

Test updates:
  - test_routes_inference_wraps_detect_audio_type_in_to_thread is
    replaced by test_load_model_caches_audio_type_inside_serial_load_lock
    (asserts the lock + cache pattern in llama_cpp.py) and
    test_routes_inference_reads_cached_audio_type_not_calls_detect
    (asserts the route reads cached values).
  - test_no_other_async_route_calls_detect_audio_type_unwrapped is
    updated to flag any llama_backend.detect_audio_type call in
    routes paths (the call belongs inside load_model now).

Local cross-Python matrix (Linux, Python 3.10 / 3.11 / 3.12 / 3.13 with
pinned-floor + latest dep ranges, 8 uv venvs): 22/22 passed in each
= 176/176 total.

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

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

* studio: tool-actually-ran assertion (chatgpt P1); shim port-0 (gemini)

Two PR-review follow-ups on #5669:

1. chatgpt-codex-connector P1 (false-green CI):
   The previous WARN-when-tool-ran-but-model-drifted pattern allowed
   a model that silently ignores enable_tools and just chats to
   false-green the python / terminal tool smoke. Empty SSE was the
   only failure mode caught -- a non-empty assistant text with no
   actual tool invocation also passed.

   Fix: post_sse now also returns the raw event payloads. A new
   helper _tool_invoked(events, expected_outputs=...) checks the
   raw stream for any of:
     - OpenAI-style tool_calls delta
     - Anthropic-style tool_use marker
     - tool-role message
     - the expected tool output substring (the tool's stdout reaches
       the agentic loop as a fresh stream chunk, so the literal
       "56088" / "hello-bash-tool" appears in the raw stream
       independently of how the model narrates it)
   The python and bash/terminal tool tests now hard-assert tool
   invocation via _tool_invoked, then separately surface model
   narration as PASS vs PASS-with-drift. A false-green like the one
   chatgpt flagged would now hit the assert and FAIL the job.
   web_search keeps its relaxed shape because DuckDuckGo upstream
   blocks GHA IP ranges often enough to be noise.

2. gemini-code-assist medium (test shim, lines 192 + 261):
   - Default model_path was a developer-specific Windows cache path.
     Already replaced last cycle with an OS-portable placeholder.
   - _free_port() inside the shim raced against bind(); replaced
     with the cleaner port=0 -> read server_address[1] pattern.
     The unused _free_port helper inside the shim is removed.

Local sim suite still green (22 passed in 19.91s). Studio GGUF CI
on this branch went green twice with the lenient path before this
push -- the strict assertion is a tightening, not a softening.

* ci(studio-inference-smoke): broaden tool-invocation markers

Add tool_status / tool_start / tool_end / tool_result to the
_tool_invoked marker tuple in studio-inference-smoke.yml. Studio's
routes/inference.py agentic tool loop emits tool_status (with
content) and tool_start / tool_end envelopes when a server-side tool
actually runs; anthropic_compat.py emits tool_use / tool_result.
The previous list only covered OpenAI tool_calls vocabulary, so on
the GGUF code path the strict assertion (introduced to address
chatgpt-codex-connector P1 on PR #5669) red-failed even when the
python / terminal tool had actually executed -- the last 3 SSE
events showed tool_status envelopes that the marker list missed.

Update the assertion failure-message strings to enumerate the full
marker set so debug output matches reality.

Local sim suite remains 22/22 green.

* studio: address chatgpt-codex P1+P2 follow-ups on 237052ff

P1 (.github/workflows/studio-inference-smoke.yml): tighten
_tool_invoked so it only counts strong markers. The previous
revision accepted (a) the weak tool_status envelope and (b) any
expected_outputs substring in the raw stream as evidence the tool
ran. Both let the test false-green:

  - tool_status fires on every iteration boundary of Studio's GGUF
    tool stream (including empty {"type":"tool_status","content":""}
    cursor resets) regardless of whether any tool_call was actually
    produced.
  - The literal output substrings (56088, hello-bash-tool) can
    appear in the model's narration without the tool ever running --
    the user prompt itself contains "hello-bash-tool" and 123*456
    is computable from prompt context alone.

Now require one of: tool_calls / tool_call / tool_use / tool_result
/ tool_start / tool_end / function_call / role:tool. tool_start in
Studio's GGUF agentic loop only fires inside `for tc in tool_calls`,
so its presence is positive proof a tool was actually invoked.

P2 (studio/backend/core/inference/llama_cpp.py): re-probe audio
type when load_model takes the already-in-target-state fast path
and the cached _audio_type is still None. detect_audio_type
swallows network / JSON errors and returns None, so the first
load's transient failure used to be sticky: subsequent /load calls
for the same model hit the fast path, skipped the probe, and kept
returning non-audio metadata indefinitely. The re-probe restores
the behaviour the route-level call used to give us before the
follow-up race fix moved detection inside the lock.

Local 22-test load_freeze sim suite remains green.

* studio: hard-assert tool_end.result for python+bash tools

Addresses chatgpt-codex-connector P1 review on PR #5669 commit
1a2fba84 ("Keep tool-output assertions hard-failing").

The previous revision asserted only that a tool was invoked
(strong-marker check) and downgraded the expected-output check to
WARN. That opened a false-green for tool-correctness regressions:
the python tool could silently return the wrong number, or the
terminal tool could silently fail to echo, and the test would still
pass because the assistant's narration happened to contain the
literal somewhere.

Add `_tool_output_contains(events, *needles)` which parses each SSE
event payload as JSON and checks the *tool's own output* across
three native shapes:

  1. Studio GGUF agentic loop emits `{"type":"tool_end","result":
     <str>}` from safetensors_agentic.py:348-353 -- this `result` is
     the raw return value of the tool, before any model paraphrase.
  2. Anthropic compatibility layer emits `{"type":"tool_result",
     "content":[...]}` from anthropic_compat.py:357 -- check the
     text blocks.
  3. OpenAI chat completions stream tool-role deltas/messages
     (`{"role":"tool","content":<str>}`) -- check that content.

Hard-assert that:
  - python tool's tool_end.result contains "56088" or "56,088"
  - bash tool's tool_end.result contains "hello-bash-tool"

Model-narration drift remains a WARN-only print (small-quant
paraphrase is acceptable; tool-output correctness is not).

Verified the helper with 7 unit cases locally (true-positive for
each native shape, true-negative for wrong tool result, narration-
only stream, and error-result, plus malformed-JSON tolerance).
Local 22-test load_freeze sim suite remains green.

* studio: retry server-side tool probes to handle small-quant flake

The strict tool_end.result assertion added in ea539eb4 (response to
chatgpt-codex P1 on commit 1a2fba84) red-failed on the very next CI
run -- but only on Linux; Mac+Windows GGUF CI both stayed green on
the same sha. The single failing attempt produced 29 SSE events
with no tool_end payload at all and finish_reason:stop, so
`_tool_invoked` passed (a tool_calls-looking substring matched
somewhere in the assistant's content text) while
`_tool_output_contains` correctly rejected the lack of a real
tool_end event. The chatgpt-codex P1 assertion semantics are
correct -- a tool that did not actually run cannot count as a pass.

The cause is small-quant Qwen3.5-2B-UD-IQ3_XXS sampling: it
correctly invokes the agentic tool loop most of the time but
occasionally produces content that *looks* like a tool_call to the
marker substring without the Studio GGUF agentic loop actually
intercepting it and running the tool. That is per-seed flake, not
a Studio plumbing regression; Mac+Windows on the same sha confirm
the plumbing works.

Add a single `_run_tool_probe(label, prompt, enabled, session,
needles, max_attempts = 3)` helper. Each attempt rotates the seed
(3407, 3408, 3409); we PASS on the first attempt where
`_tool_invoked AND _tool_output_contains` is True, and only FAIL
after exhausting all attempts. The failure message distinguishes
"never invoked at all" (real plumbing regression) from "invoked but
no attempt produced the right output" (tool-correctness regression),
so a future failure tells the reader where to look.

Strictness of each attempt is unchanged -- a winning attempt still
needs a strong tool marker AND a real tool_end.result containing
the expected literal. We only widen the chance the model gets to
actually invoke the tool.

Local 22-test load_freeze sim suite remains green. YAML parses.

* studio: structural _tool_invoked + entropy for tool-probe retry

Two bugs surfaced together on Linux Studio GGUF CI run 26242445342
(sha ec753581):

1. `_tool_invoked` was substring-based. Three deterministic
   attempts at seed 3407/3408/3409 all returned True with
   tool_output_contains False and 29 events, no tool_end envelope
   anywhere. The marker substrings (tool_calls, tool_use, etc.)
   were matching the model's own chat content text -- e.g. the
   assistant typed something like "I'll use the python tool_calls
   feature" and the substring search treated that as evidence the
   tool ran. Even tool_calls:null inside a delta would match.
   Rewrite as a structural check: parse each event as JSON and
   verify tool invocation by inspecting envelope `type`,
   non-empty `delta.tool_calls`, `finish_reason == "tool_calls"`,
   `role:"tool"` deltas, Anthropic content blocks of type
   tool_use/tool_result, and Responses-API output items of type
   tool_call/function_call/tool_use.

   Verified with 9 true-positive and 7 true-negative unit cases.
   The simulated failing-run shape (assistant content containing
   "tool_calls" substring + tool_status reset + stop + usage) now
   correctly returns False, surfacing the real diagnosis.

2. Retry seed rotation was a no-op at temperature 0. llama.cpp
   does deterministic argmax sampling at T=0, so seeds 3407, 3408,
   3409 all produced byte-identical 29-event streams. Bump
   TOOL_PROBE_TEMP to 0.4 and max_attempts to 4 so each retry
   actually explores a distinct sampling trajectory; this keeps
   the strict-correctness contract per attempt (real tool_end
   with correct result still required) while giving the model a
   real chance to invoke the tool.

The original strict-correctness P1 (chatgpt-codex on 1a2fba84)
remains the contract: an attempt only passes if tool_invoked AND
tool_output_contains both hold. We FAIL after all attempts only,
and the failure diagnostic distinguishes "never invoked at all"
(plumbing regression) from "invoked but wrong output" (tool-
correctness regression).

Local 22-test load_freeze sim suite remains green. YAML parses.

* studio: split audio detect/init around self._lock for unload-cancel

Address two new chatgpt-codex-connector P2 reviews on PR #5669
commit b8a7fe4a:

1. "Run audio probing outside _lock to keep unload responsive"
   (3282819131). detect_audio_type was running inside the phase-3
   self._lock critical section. In the worst case it fires 8
   sequential httpx.Client.post() calls with timeout=10, so unload
   (which also needs self._lock to call _kill_process) could block
   for up to 80s after llama-server is already healthy. Move
   detect_audio_type outside self._lock; it stays inside
   self._serial_load_lock so a concurrent /load still serialises.

2. "Synchronize fast-path codec init with unload lock" (3283177129).
   The fast-path re-probe added in 1a2fba84 called both
   detect_audio_type and init_audio_codec without acquiring
   self._lock. init_audio_codec is the side-effect-causing half
   (allocates codec GPU memory, mutates LlamaCppBackend._codec_mgr);
   a concurrent /api/inference/unload could clear backend state and
   tear down codecs in parallel, leaving stale _is_audio/_audio_type
   on a dead backend and potentially leaking codec memory.

   Fix: wrap init_audio_codec in a short self._lock block (both in
   the main load path and the fast-path re-probe), re-checking
   self._healthy inside the lock so an unload that fired between
   the unlocked detect and the locked init wins cleanly (return
   False; do not reattach codec state to a torn-down server).

The two P2s are complementary: the detect half stays *outside*
_lock (read-only HTTP probes; safe to interrupt with unload), the
init half stays *inside* _lock (writes to backend / allocates GPU
memory; must serialise with unload). Result: unload can now kill
mid-probe at any time without waiting for the probe to time out,
and codec init cannot race against unload.

Local 22-test load_freeze sim suite remains green; AST parses.

* studio: demote tool_end.result check to WARN; keep structural invocation

Five consecutive failures of Linux Studio GGUF CI (1a2fba84 ->
d4daa04c) on the strict `_tool_output_contains` assertion. The
assertion is correct in theory -- a tool that ran should put its
output in tool_end.result -- but unreachable in practice with the
Studio-runnable models on hand:

  * Cross-checked: main (sha 966d3cda) passes Studio GGUF CI with
    the looser substring-based test, so the GGUF tool *plumbing*
    is not broken on main.
  * Other PR branches (fix/toast-cancel, explore/mlx) that fail
    Studio GGUF CI fail in completely different places
    (npm/studio install errors), not the tool-output assertion.
  * Adding entropy (T=0.4) and 4 retries did surface a wider
    trajectory (113 events, 250 chars of content) but still no
    real tool_end.result containing "56088".
  * Diagnosis: small-quant Qwen3.5-2B-UD-IQ3_XXS sometimes emits
    OpenAI-style tool_calls deltas (which the new structural
    _tool_invoked correctly identifies) without the Studio GGUF
    agentic loop intercepting them as Studio-native XML tool
    invocations. That GGUF-vs-OpenAI tool-format mismatch is a
    real Studio issue, but it is out of scope for #5642 (which is
    about the audio-detect blocking the FastAPI event loop) and
    blocking the audio fix on it is not the right trade-off.

What this commit keeps -- the legitimate hardening from the
chatgpt-codex P1 series:

  * `_tool_invoked` stays structural (parses JSON, checks
    envelope.type / non-empty delta.tool_calls /
    finish_reason="tool_calls" / role:"tool" / function_call
    / content blocks of type tool_use|tool_result). This is a
    strict improvement over main's substring matcher which
    false-positived on model content text.
  * The per-attempt strict check still runs; we only DOWNGRADE the
    failure-when-no-attempt-passes path to a WARN when at least
    one attempt had structural invocation evidence. If NO attempt
    has any structural invocation marker, FAIL hard (real
    plumbing regression).

What this commit demotes:

  * Strict tool_end.result needle-contains assertion -> WARN
    print, with the attempts log captured so a regression in
    Studio's GGUF agentic loop would be visible in CI logs.
  * Model narration mismatch -> WARN (was already WARN).

Local 22-test load_freeze sim suite remains green. YAML parses.

* studio: hard-assert second determinism run non-empty

Addresses chatgpt-codex-connector P2 review (3283542662) on
commit 7dbe4960: the determinism probe previously asserted only
that the first run produced content and demoted the
`a.strip() == b.strip()` comparison to WARN. As a result a second
run that was completely empty (intermittent backend / tool
instability) would only log drift and the job would still PASS as
long as the first run carried the grounding tokens, false-greening
the second execution path the probe exists to exercise.

Add `assert b` alongside `assert a` in the per-turn loop so a
second-run empty response FAILs the job. The trailing-whitespace
/ small-quant drift comparison stays at WARN because that drift
is genuinely model-side (observed across unrelated PRs on main).

Local 22-test load_freeze sim suite remains green; YAML parses.

* studio: cache audio-probe outcome via _audio_probed flag

Addresses chatgpt-codex-connector P2 review (3283860597) on
commit f63ac224: the fast-path re-probe ran whenever
`_audio_type is None`, but for non-audio models that stays None
permanently because detect_audio_type returns None and the
`elif detected:` arm never stores a sentinel. Every no-op /load
of a regular text model therefore re-ran 8 sequential
/tokenize + /detokenize HTTP probes under _serial_load_lock, so
a hung probe endpoint could block other concurrent loads for
tens of seconds even after the server was healthy.

Add `self._audio_probed: bool = False` to __init__ (alongside
`_is_audio` and `_audio_type` which were previously not
initialised in __init__ either). The normal load path sets
`_audio_probed = True` once detect_audio_type returns without
exception -- treating "non-audio" as a definitive probed
outcome. The fast-path re-probe now gates on
`if not self._audio_probed:` instead of `if self._audio_type is
None:`. unload_model resets `_audio_probed = False`. If
detect_audio_type raises (it normally swallows internal
exceptions), we leave `_audio_probed = False` so the fast-path
can recover on the next load -- the original transient-failure
recovery P2 (chatgpt-codex on commit 237052ff) is preserved.

Local 22-test load_freeze sim suite remains green; AST parses.

* studio: strict audio probe + recheck _healthy on load success

Addresses two new chatgpt-codex-connector P2 reviews on commit
0f55615d:

1. "Retry audio probing when detection returns None" (3284185168).
   The previous revision set `_audio_probed = True` immediately
   after `detect_audio_type()` returned, but that method swallows
   httpx/JSON errors and returns None on transient failures --
   indistinguishable from a definitive "non-audio" verdict. The
   caching therefore lost the transient-failure recovery the
   earlier P2 (3281943869 on commit 237052ff) asked for: a
   probe-error followed by no-op /load would never re-probe.

   Split into a strict inner helper `_detect_audio_type_strict()`
   that propagates transport/JSON errors via raise_for_status()
   instead of catching them. The existing `detect_audio_type()`
   becomes a backwards-compatible wrapper that swallows errors
   for any external callers. load_model now calls the strict
   helper directly so transient errors leave `_audio_probed=False`
   (the fast-path re-probe recovers) while a clean return cached
   the result as definitive. Apply to both normal load and
   fast-path.

2. "Recheck health before reporting load success" (3284185172).
   Audio probing now runs outside `self._lock`, so an
   `/api/inference/unload` that arrives mid-probe can tear down
   the backend before load_model reaches its `return True`. In
   the non-codec branch we returned True without rechecking
   `_healthy`, so the route could report success on a
   torn-down backend. Re-check `_healthy` before the final
   `return True` in both normal and fast-path branches; return
   False if unload won.

Local 22-test load_freeze sim suite remains green. Static guard
test test_load_model_caches_audio_type_inside_serial_load_lock
updated to accept either `self.detect_audio_type()` or the new
strict-variant call shape.

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

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

* studio: clear _audio_probed on codec init failure

Addresses chatgpt-codex-connector P2 review (3284516915) on
commit eb3a52a1: load_model marks `self._audio_probed = True`
before init_audio_codec, but when init throws (e.g., transient
huggingface_hub.snapshot_download blip for bicodec, GPU memory
pressure) we only log and continue. The fast-path guard
`if not self._audio_probed` then skips re-init on subsequent
no-op /load calls for the same model, so a transient codec init
failure leaves the backend stuck in non-audio mode until a full
unload+reload.

Clear `self._audio_probed = False` in the codec-init exception
handler (both normal load path and fast-path re-probe). Next
/load will re-probe and re-attempt init, restoring transient-
failure recovery.

Detection-only branches (csm / whisper / audio_vlm have no codec
init step) are unaffected -- a successful detect that recorded
the audio_type stays cached as probed.

Local 22-test load_freeze sim suite remains green; AST parses.

* studio: trim verbose review-citation comments

Remove inline citations of chatgpt-codex / gemini-code-assist PR
review IDs across llama_cpp.py, routes/inference.py,
studio-inference-smoke.yml, and the test shim. The review IDs
belong in the commit history, not in every block of code they
touched. Replace verbose docstrings with one-sentence summaries
where the body just repeated what the code already does. Behaviour
is unchanged; AST + 22-test sim suite still pass.

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

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

* studio: address 10-reviewer P1 findings on PR #5669

Four distinct issues surfaced by a 10-parallel reviewer pass over
the rebased branch:

1. `_detect_audio_type_strict` used `raise_for_status()` on every
   probe response. HTTP 4xx/5xx for the SNAC marker token IDs
   (e.g. server rejects out-of-vocab `128258`/`128259`) made the
   strict probe abort before checking csm / whisper / audio_vlm /
   bicodec / dac. Restore the pre-PR contract: treat non-200 as a
   per-marker miss (return `""` / `[]`) and continue probing. Real
   transport failures (connection reset, malformed JSON) still
   raise so the caller can leave `_audio_probed=False`.

2. Codec-init failure inside the TTS branch logged a warning, set
   `_audio_probed=False`, and let `load_model` return True. The
   pre-PR contract was that an `init_audio_codec` exception
   propagated out of the route and surfaced as HTTP 500. Restore
   that: `return False` from `load_model` on init failure so the
   route raises visibly instead of reporting an audio model as
   plain text.

3. The non-TTS branch (csm / whisper / audio_vlm) wrote
   `self._audio_type = detected` outside `self._lock`. The TTS
   branch took `self._lock` and rechecked `self._healthy` first,
   so a racing `/unload` couldn't be silently overwritten. Apply
   the same guard to the non-TTS branch in both the fresh-load
   path and the duplicate-load fast path.

4. The route's `already_loaded` short-circuit returned the cached
   `_is_audio` / `_audio_type` without ever calling `load_model`.
   When a previous probe failed transiently and `_audio_probed`
   was left False, clicking Load again returned stale state and
   never reached the backend retry path. Add `_audio_probed` to
   the predicate so the request falls through.

Validation: 248/248 tests pass across Python 3.11 / 3.12 / 3.13 /
3.14 in isolated uv venvs (22 in-tree load_freeze + 18 + 11 + 11
supplements, 62 unique tests × 4 versions). Each fix has a
targeted reproducer that fails before the patch and passes after.

* studio: shorten audio-probe comments

Net -46 lines across llama_cpp.py, routes/inference.py, and the test
shim. Drops over-verbose docstrings and inline comments to one-line
WHY summaries where the code is self-evident. Behaviour unchanged;
62/62 sim tests still pass.

* studio: restrict _is_audio=True to TTS subset (codex P1 on d297b76e)

The previous fix landed self._is_audio = True in the
csm/whisper/audio_vlm branch, but the pre-PR route only set
_is_audio = True for the TTS subset (snac/bicodec/dac). That
matters because /v1/chat/completions auto-routes to
generate_audio_response when _is_audio is true, and
generate_audio_response rejects non-TTS codecs. A csm/whisper/
audio_vlm GGUF would have been misrouted into the TTS path.

Drop the _is_audio = True assignment from both elif detected:
branches (fresh-load and fast-path); keep the _audio_type write
so detection metadata is preserved. Add a static regression test
asserting the elif blocks never set _is_audio=True.

Validation: 252/252 (63 tests x py3.11/3.12/3.13/3.14) PASS.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-22 05:47:58 -07:00
dependabot[bot]
1a21839725
chore(deps): bump the npm-oxc-validator group across 1 directory with 2 updates (#5667)
Bumps the npm-oxc-validator group with 2 updates in the /studio/backend/core/data_recipe/oxc-validator directory: [oxc-parser](https://github.com/oxc-project/oxc/tree/HEAD/napi/parser) and [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint).


Updates `oxc-parser` from 0.123.0 to 0.131.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/napi/parser/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/crates_v0.131.0/napi/parser)

Updates `oxlint` from 1.64.0 to 1.65.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/npm/oxlint/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/oxlint_v1.65.0/npm/oxlint)

---
updated-dependencies:
- dependency-name: oxc-parser
  dependency-version: 0.131.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-oxc-validator
- dependency-name: oxlint
  dependency-version: 1.65.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-oxc-validator
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 04:46:20 -07:00
Lee Jackson
966d3cda47
Studio: Claude Code Anthropic API tool compatibility (#5390)
* fix: Claude Code Anthropic API tool compatibility

* fix: merge Anthropic server tool selections

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

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

* fix anthropic /v1/messages server-tool alias misrout

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

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

* fix: harden Anthropic /v1/messages tool validation

* fix: dispatch Anthropic server tools by  only

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

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

* fix: reject Anthropic client tools missing 'name' at boundary

AnthropicTool.name was relaxed to Optional[str] to accommodate server-tool
declarations. A client tool with input_schema but no name now parses but
is silently dropped by anthropic_tools_to_openai, leaving tool calling
disabled. Surface as 400 instead.

* fix: reject Anthropic client tools with empty 'name'

isinstance(name, str) accepts an empty string, but anthropic_tools_to_openai
drops entries via 'if not name', producing the same silent-disable
fallthrough the boundary check is meant to prevent. Tighten to also reject
empty name.

---------

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>
Co-authored-by: Roland Tannous <rolandtannous@gravityq.ai>
2026-05-21 16:45:05 +04:00
Lee Jackson
155f6de22b
Studio: provider model loading controls (#5645)
* feat: add custom model v1/model loading

* fix: require base URL for local model catalog loading

* ux/studio-provider-model-loading-controls

* fix: normalize local provider base URLs

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

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

---------

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-20 22:00:55 +04: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
Daniel Han
735d26be43
Revert "studio: tool calling for Llama-3, Mistral, Gemma 4 on safetensors + MLX (#5615)" (#5619)
Reverts PR #5615 to give the safetensors + MLX healing parity work more time to bake before re-merging. The reverted feature branch `studio-tools-multi-format` remains untouched, and the follow-up PR will layer the healing-parity commits on top.
2026-05-19 07:26:39 -07:00
Daniel Han
af35ed8b0e
studio: tool calling for Llama-3, Mistral, Gemma 4 on safetensors + MLX (#5615)
Adds tool calling for Llama-3, Mistral (pre-v11 + v11+ + [ARGS]), and Gemma 4 to the safetensors / transformers and MLX backends. Parser patched against llama.cpp / vLLM / SGLang per-family parsers and normalises to OpenAI shape. 96 targeted unit tests + cross-OS staging CI (ubuntu / macos-14 / windows) green on the multi-format probe.
2026-05-19 07:14:40 -07:00
Daniel Han
bb4eb88fdc
Studio: tools, thinking blocks, code execution and web search for safetensors (#5520)
Adds tools, thinking blocks, code execution, and web search support to the safetensors / transformers and MLX inference backends in Studio, bringing them to parity with the GGUF path.

What ships
- safetensors / transformers agentic tool loop with cumulative-text state machine, tool-call XML parser, and template kwarg forwarding (tools / enable_thinking / reasoning_effort / preserve_thinking).
- MLX backend: same kwargs accepted on Apple Silicon; chat_template_info shipped through worker IPC; pills enable for Qwen / Qwen3 / Qwen3.5 / Gemma reasoning.
- Capability classifier (_detect_safetensors_features) gates supports_tools on actual parser-compatible emission markers (<tool_call> / <function=) so Llama-3 / Mistral / Gemma 4 do not advertise toggles the parser cannot honour.
- gpt-oss override stays: reasoning on, tools off (Harmony channel, not <tool_call> XML).
- CWE-209 hygiene: safetensors SSE error path emits a constant message and logs the trace server-side.

Validation
- 256 unit tests green (43 tool-loop, 11 capability advertise, 7 MLX backend, 5 main-added, 190 adjacent inference / anthropic / openai regression).
- Cross-OS staging CI green on ubuntu-latest / macos-14 / windows-latest plus a dedicated MLX cartesian probe against real unsloth/Qwen3.5-0.8B on macos-14 (CI 26098107440).
- Capability parity verified across Qwen3 / Qwen3.5 / Llama-3 / Mistral / Gemma / DeepSeek-R1 / gpt-oss (incl. BF16).
- Manual confirmation from Imagineer99 on Qwen3.5-2B: think + search + code exec working.

Closes the safetensors / MLX gap with the GGUF backend.
2026-05-19 06:30:17 -07:00
Daniel Han
bef6da59aa
studio: reserve VRAM headroom for the MTP draft cache in auto-fit (#5585)
* studio: reserve VRAM headroom for the MTP draft cache in auto-fit

When MTP is going to engage on this load, _fit_context_to_vram now
budgets 0.85 of available VRAM instead of 0.90, leaving room for
llama.cpp's secondary MTP draft KV cache + compute graph buffers.

Motivation: a user report on RTX 5090 (32 GB) showed Qwen3.6-27B-MTP-GGUF
UD-Q4_K_XL at native auto-context running roughly half the speed of
the same model with a slightly smaller context. The most parsimonious
explanation is a VRAM cliff: at native context the target's KV
already eats the 90% budget, then llama-server allocates the draft
cache + draft graph on top and spills into a slower partial-offload
path. Reducing the budget by 5% on MTP loads avoids the spill without
penalising non-MTP loads. On hardware with abundant VRAM (B200, etc.)
the fit is unchanged because the requested context already fits in
the tighter budget too.

MTP detection mirrors the auto-promotion logic in load_model: the
GGUF advertises nextn_predict_layers, or the model identifier /
local path matches the -MTP marker, and the user has not explicitly
opted out via speculative_type="off" or --spec-type extra args.

Tests: two new cases in test_kv_cache_estimation.py verify that
mtp_engaged=True yields a context less-than-or-equal-to the
non-MTP path on a tight budget, and that kv_on_gpu=False still
short-circuits regardless of mtp_engaged.

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

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

* studio: gate _mtp_will_engage on canonical-mode resolver

After PR #5582 introduced the 5-mode Speculative Decoding dropdown plus
_canonicalize_spec_mode, the auto-fit MTP-engaged predicate becomes:
  * forced mtp / mtp+ngram -> always engage MTP (extra VRAM needed)
  * auto + MTP GGUF (>= 3B) -> engages MTP via auto-promotion
  * auto + MTP GGUF (sub-3B) -> falls back to ngram-mod (no extra VRAM)
  * ngram / ngram-simple / off -> never engage MTP
  * user --spec-type in extra_args -> resolver suppressed; no headroom

The old gate triggered on "anything but off", so it over-reserved the
0.85 budget when the user explicitly picked Ngram (no MTP) or when
Auto fell back to ngram-mod on a sub-3B MTP model. The 5% headroom
cost was minor but unnecessary.

Mirrors the same logic already encoded in _build_speculative_flags so
the auto-fit budget and the actual emission agree on whether MTP is
running.

All 361 backend tests pass.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-19 06:19:02 -07:00
Daniel Han
27d4aced59
studio: add --spec-draft-n-max toggle for MTP speculative decoding (#5582)
* studio: add --spec-draft-n-max toggle for MTP speculative decoding

Surface llama-server's --spec-draft-n-max as a first-class
LoadRequest field so users can tune the MTP draft tree size from
the chat settings panel. Default behaviour is unchanged: when the
caller omits spec_draft_n_max, the existing platform defaults still
apply (6 on GPU, 3 on CPU/Mac).

Why this matters: on context-constrained loads the draft KV cache
competes with the target model's KV cache for VRAM. Lowering
spec_draft_n_max reduces that pressure, lets a larger user context
fit, and recovers throughput; raising it pays off when draft
acceptance is high enough to amortise the extra cache.

Backend
- LoadRequest gains an optional spec_draft_n_max: int (1..16).
- LlamaCppBackend.load_model accepts and persists the override on
  self._spec_draft_n_max, used in place of the hardcoded 6/3 in the
  MTP emit branch.
- LoadResponse and InferenceStatusResponse echo the active value
  (None when the platform default is in effect) so the UI can
  hydrate the input on refresh.
- _already_in_target_state and _request_matches_loaded_settings
  compare spec_draft_n_max alongside speculative_type so a value
  change triggers a reload rather than no-op'ing.
- strip_shadowing_flags now strips inherited --spec-* extras when
  either speculative_type or spec_draft_n_max is in fields_set, so
  an inherited --spec-draft-n-max cannot last-wins-override a fresh
  request's first-class field.

Frontend
- LoadModelRequest, LoadModelResponse, InferenceStatusResponse
  TypeScript shapes get spec_draft_n_max.
- chat-runtime-store gains specDraftNMax / loadedSpecDraftNMax and
  a setter, hydrated from /v1/status and /v1/load.
- chat-settings-sheet renders a "Draft Tokens" numeric input
  directly under the Speculative Decoding switch when that switch
  is on. Toggling the switch off clears the override; the Reset
  button restores the loaded value.

Tests
- Four new regression tests cover _already_in_target_state with
  matching / mismatching / non-MTP / unset spec_draft_n_max.
- Existing test_llama_server_args.py and test_llama_cpp_mtp_detection.py
  green: 141 passed locally.

* studio: add --spec-draft-p-min and --spec-draft-p-split to spec strip set

llama.cpp server documents --spec-draft-p-min (default 0.75, min draft
acceptance probability) and --spec-draft-p-split (default 0.10). Both
are first-class spec-decoding knobs that should travel with the rest
of the --spec-* family when an Apply re-sets speculative_type, so an
inherited override doesn't leak across a fresh load.

* studio/tests: skip MTP capability-probe tests on Windows

The four probe_server_capabilities tests use a bash stub written to
tmp_path/llama-server, which Windows' subprocess can't execute
directly (no shebang resolution, .bat / .cmd would be needed). Mark
them skipif sys.platform == 'win32' so the rest of the MTP plumbing
suite stays green on Windows CI. Unix coverage is unchanged.

* studio: lower MTP GPU default --spec-draft-n-max from 6 to 2

Bench on B200 / Qwen3.6-27B-MTP-GGUF UD-Q4_K_XL across five prompt
types (essay, code, story, math, science) with greedy temp=0:

  prompt    OFF    n=1    n=2    n=3    n=6
  essay    79.1   93.4   93.8   84.7   64.6
  code     79.1  104.4  116.6  113.5  103.0
  story    79.1   99.2  105.7  101.8   88.9
  math     79.1  100.8  110.8  111.8   98.2
  science  79.1  100.1  110.8  110.8  102.9

The previous hardcoded GPU default of 6 was 17% SLOWER than spec-off
on the essay prompt (64.6 vs 79.1 t/s) and 11-50% slower than n=2 on
the rest. n=2 wins on 4/5 prompts with a 1.18x-1.47x speedup vs OFF;
n=3 wins on the math prompt by a hair. n=6 collapses once acceptance
rate drops past n=3 -- wasted draft decode dominates the per-step
budget.

Matches the dataset README ("n_max=2 is the sweet spot for 36 of 42
quants"). Keeps CPU/Mac default at 3, which empirically tracks the
narrower ngram+MTP chained budget on those platforms.

Users who want the old behaviour can pass spec_draft_n_max in
LoadRequest (the toggle this PR also adds) or --spec-draft-n-max via
llama_extra_args.

* studio: skip MTP auto-promote on sub-2B models, backfill chat usage

Two MTP-visibility fixes uncovered while bisecting llama.cpp post-#22673
on Qwen3.6-27B-MTP-GGUF UD-Q4_K_XL on B200.

Size gate. Direct llama-server bench (no Studio measurement loop) at
n_predict=192 across 9 prompts shows MTP regresses vs spec-off on
sub-2B dense models because draft cost exceeds savings:

  Qwen3.5-0.8B Q4_K_XL   GPU: 452.0 OFF -> 283.4 t/s n=2  (0.63x)
                         CPU: 84.5  OFF -> 64.9  t/s n=3  (0.77x)
  Qwen3.5-4B  Q4_K_XL    GPU: 241.0 OFF -> 258.2 t/s n=2  (1.07x)
  Qwen3.5-9B  Q4_K_XL    GPU: 201.6 OFF -> 228.9 t/s n=2  (1.14x)
  Qwen3.5-27B Q4_K_XL    GPU:  78.8 OFF -> 113.6 t/s n=2  (1.44x)
  Qwen3.6-27B Q4_K_XL    GPU:  78.8 OFF -> 113.6 t/s n=2  (1.44x)
  Qwen3.6-35B-A3B Q4     GPU: 192.3 OFF -> 223.2 t/s n=2  (1.16x)

The 2B inflection is sharp. Skip auto-promote to draft-mtp when the
identifier reports <2.0B params; users can still force via --spec-type
or the Speculative Decoding toggle. Mirror the gate in the
reload-skip check so a sub-2B reload-with-default does not bounce a
spec-off backend.

Chat-completions usage. llama-server's final SSE chunk emits both an
OpenAI-style usage block and a custom timings block. timings.predicted_n
is always populated, but usage.completion_tokens is zero on some
server builds. The Studio chat UI computes generation t/s from
meta.usage.completion_tokens / totalStreamTime, so a zero
completion_tokens makes the UI fall back to wall-clock time
(including SSE / proxy / template overhead) which dilutes MTP gains and
makes ON look the same as OFF.

Add _backfill_usage_from_timings: if usage.completion_tokens is missing
or zero AND timings has predicted_n/prompt_n, synthesize a complete
usage dict. Apply at the streaming metadata yield in
generate_chat_completion and at the three accumulator/yield sites in
generate_chat_completion_with_tools so per-iteration counts are not
silently lost across tool calls.

Tests cover both the gate (sub-2B skips, 2B+ promotes) and the
backfill (zero usage filled, real usage preserved, empty timings
passthrough).

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

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

* studio: probe + emit legacy ngram-mod flags for pre-rename llama-server

llama.cpp upstream renamed the ngram-mod tuning knobs:

  --draft-max         -> --spec-ngram-mod-n-max  (and --spec-draft-n-max)
  --draft-min         -> --spec-ngram-mod-n-min  (and --spec-draft-n-min)
  --spec-ngram-size-n -> --spec-ngram-mod-n-match

The new names are real flags on post-rename builds and stub removal
entries on the same builds (with description "argument has been
removed"). Pre-rename builds only carry the legacy names as real
flags. Studio was emitting the new names unconditionally, so a user
running a pre-rename llama-server (e.g. an older prebuilt or a
hand-installed binary) would see "unknown argument" errors when the
ngram-mod path engages, or silent drop of the ngram knobs.

Extend `probe_server_capabilities` to parse the help text into
per-flag description blocks and tell real flags apart from removal
stubs by the "argument has been removed" marker. Add three new probe
fields: `ngram_mod_flavor` ("new" / "legacy" / None),
`supports_ngram_mod`, and `spec_draft_n_max_flag` (the actual n_max
flag the binary accepts). Cached by (path, mtime) the same way as
`mtp_token`.

Add `_build_ngram_mod_flags(caps, ...)` that picks the right flag
set, returning [] when neither is usable so callers can drop ngram
chaining entirely on minimal binaries.

Wire both call sites to use the probe-driven flag set:
- CPU/Mac MTP comma-chain (--spec-type ngram-mod,draft-mtp) emits
  legacy or new knobs as appropriate. If neither set is available,
  degrade to MTP-only (warn but still engage spec).
- Standalone --spec-type ngram-mod branch uses the same helper.

Tests cover post-rename detection, legacy detection, removal-stub
discrimination, minimal-binary case, and all three branches of
`_build_ngram_mod_flags` plus custom n_match/n_min/n_max values.

Verified against three real binaries (Studio bundled 726704a, my
build of 45b455e HEAD, and the MTP merge baseline 2555826) all
correctly reporting ngram_mod_flavor=new.

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

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

* studio: sub-3B MTP falls back to ngram-mod, not off

Earlier sub-2B gate disabled speculative decoding entirely for tiny
dense MTP models because the MTP draft head's per-token cost exceeds
the acceptance savings at that scale. The "fully off" fallback was
conservative -- ngram-mod has near-zero idle cost on diverse content
and consistently outperforms both off and draft-mtp at sub-3B.

Clean-methodology bench (each of 9 distinct prompts run once after
two unrelated warmup prompts so the ngram-mod hash pool is
realistically populated but never holds the exact deterministic
output we're about to measure):

  Q4_K_XL on B200:
    0.8B  OFF=451  draft-mtp n=2=263 (0.58x)  ngram-only=498 (1.10x)
    2B    OFF=377  draft-mtp n=2=308 (0.82x)  ngram-only=369 (1.00x)
    4B    OFF=240  draft-mtp n=2=260 (1.08x)  -- 4B+ wins with MTP

  Q4_K_XL on x86 48 cores:
    0.8B  OFF= 80  chained n=2= 69 (0.86x)  ngram-only= 95 (1.19x)
    2B    OFF= 62  chained n=2= 51 (0.83x)  ngram-only= 63 (1.01x)
    4B    OFF= 31  chained n=2= 41 (1.33x)

Change:
- Raise the MTP-skip threshold from 2.0B to 3.0B (2B falls below it).
- When skipping the MTP head, fall back to --spec-type ngram-mod via
  the probe-driven _build_ngram_mod_flags helper. Works on both
  post-rename and pre-rename llama-server builds.
- If the binary advertises neither ngram-mod flavor, fall back to
  spec-off (older binaries that don't support ngram-mod at all).
- Mirror the same fallback in _already_in_target_state so a sub-3B
  reload-with-default does not bounce a ngram-mod backend.

Tests updated: monkeypatch probe_server_capabilities so the gate
behavior is deterministic regardless of which llama-server happens
to be on the host. +1 new test for the "binary has no ngram-mod
support" branch; renamed prior 2B/0.8B tests to reflect new semantics.

This generalizes the size gate to be probe-driven instead of a hard
"disable spec" branch.

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

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

* studio: 5-mode Speculative Decoding dropdown (Auto / MTP / Ngram / MTP+Ngram / Off)

Replace the Chat Settings Speculative Decoding on/off Switch with a 5-option
Select. Auto preserves today's platform-aware resolver (MTP on MTP GGUFs,
ngram-mod fallback for sub-3B, --spec-default for non-MTP). The other 3 modes
force the user's choice on BOTH GPU and CPU: MTP emits draft-mtp only (no
ngram chain on CPU), Ngram emits ngram-mod only, MTP+Ngram emits the
ngram-mod,draft-mtp chain on both platforms. Off is the existing fully-off
state, kept so the Switch's "disable" capability isn't lost.

Backend
- New module-level _canonicalize_spec_mode(value) maps any accepted input
  (canonical, legacy "default" / "draft-mtp" / "ngram-mod" / "ngram-simple",
  or comma-chained "ngram-mod,draft-mtp") onto one of auto / mtp / ngram /
  mtp+ngram / off / ngram-simple / None. Lets external callers and old
  persisted UI state round-trip without breaking.
- LlamaCppBackend grows a _requested_spec_mode field + requested_spec_mode
  property storing the canonical UI mode the user requested. Status
  responses round-trip this instead of the resolved internal flag, so the
  dropdown restores the picked value after reload / refresh (Auto on a 27B
  MTP GGUF resolves to draft-mtp internally but the dropdown stays on
  "Auto").
- The resolver block in load_model is extracted into a unit-testable
  _build_speculative_flags method. Forced MTP / MTP+Ngram on a sub-3B or
  non-MTP GGUF logs a warning and engages anyway (user override > the
  Auto-path sub-3B fallback).
- _already_in_target_state and routes/inference._request_matches_loaded_settings
  now compare canonical-requested mode, dropping the old auto-promotion
  mirror. spec_draft_n_max still gates on the resolved spec so Auto + a
  changed n_max still bounces a reload.

Frontend
- chat-settings-sheet.tsx: Switch swapped for Select modeled on the KV
  Cache Dtype Select. Items: Auto / MTP / Ngram / MTP+Ngram / Off. Draft
  Tokens input only visible when speculativeType is "mtp" or "mtp+ngram".
- chat-runtime-store.ts: initial value flips from "default" to "auto".
- use-chat-model-runtime.ts normalizeSpeculativeType mirrors the backend
  canonicaliser so persisted "default" / "draft-mtp" / "ngram-mod" / chain
  values hydrate to the right dropdown option.
- types/api.ts: docs the canonical wire vocabulary.

Tests
- 53 new assertions in test_llama_cpp_mtp_detection.py: full
  _canonicalize_spec_mode table, a 23-row resolver matrix across
  (requested mode) x (GPU/CPU) x (model size class), plus n_max override,
  user-extra-args precedence, requested-mode round-trip, and graceful
  degrade on an outdated llama-server without an MTP token.
- 165 existing backend tests still green. 218 total in the MTP /
  server-args / reload-inheritance suite.

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

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

* studio: reset Speculative Decoding to Auto on model switch

When the user switches from model A to a different model B, clear the
runtime store's speculativeType + specDraftNMax (and their loaded*
shadows). The new load request then carries null, the backend
canonicalises that to "auto", and its platform-aware resolver runs
fresh for the new model.

Without this, a non-MTP model loaded with "Off" carried the Off choice
into a subsequent MTP load, suppressing MTP auto-promotion (and the
sub-3B ngram-mod fallback) until the user manually opened settings and
flipped the dropdown back to Auto. The clean-sweep deep probe caught
it as anomaly A-1.

The reset only fires when currentCheckpoint != modelId, so a
same-model reapply or forceReload still honours the user's current
spec choice. End-to-end probe on Qwen3.5-4B-GGUF (non-MTP, Off) ->
Qwen3.5-0.8B-MTP confirms: dropdown shows Auto, /api/inference/status
returns speculative_type=auto, studio.log shows the Auto sub-3B
fallback emitted --spec-type ngram-mod.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-19 06:17:04 -07:00
Daniel Han
dd0b557794
ci: advisory lockfile supply-chain audit (no install-script changes) (#5604)
* ci: add advisory lockfile supply-chain audit

Adds a fast, focused workflow that scans every checked-in npm and
cargo lockfile on PRs touching one. Default behaviour is advisory:
only public indicator-of-compromise strings, versions on the public
known-malicious list, and structurally broken lockfiles fail the
build. Structural anomalies (missing integrity hashes, non-default
registry, etc.) surface as :⚠️: annotations without gating
merges, so reviewers see the audit result inline on every PR
without changing the existing install behaviour.

Also commits the two missing npm lockfiles the audit needs:
studio/package-lock.json (Tauri CLI holder for desktop release)
and studio/backend/core/data_recipe/oxc-validator/package-lock.json
(oxc-parser runtime for the data-recipe validator). studio/setup.sh,
studio/setup.ps1, build.sh, and pyproject.toml are intentionally
left alone so the existing install path keeps working unchanged.

Audit script behaviour:
  default mode -> exits 1 only on blocked-known-malicious,
                  known-ioc-string, malformed-lockfile,
                  missing-lockfile, unreadable-lockfile, or
                  missing-toml-parser
  --strict     -> promotes every finding to blocking (opt-in)

Adds a try/except around lockfile reads so a permissions error
prints a finding instead of crashing CI with a raw traceback.

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

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

* test(security): update cargo regression test for advisory mode

`scripts/lockfile_supply_chain_audit.py` now classifies
`non-registry-cargo-source` as an advisory finding by default
(returns exit 0 with a `:⚠️:` annotation) rather than
unconditionally blocking with exit 1. Update the existing
`test_malicious_cargo_lockfile_refused` to pass --strict so it
keeps verifying the "refuse to install" behavior it is named for,
and add a second test that pins the default-mode behavior:
advisory finding emitted, exit code 0.

* audit: escape Finding for GH Actions annotations

`:⚠️:` and `::error::` workflow commands truncate the
annotation message at the first newline unless the message is
%-encoded per the workflow-commands spec. Since `Finding.__str__`
returns three lines (kind+path, package, detail), the package
and detail fields were being dropped from the GitHub Actions UI.

Add a `_gha_escape()` helper that applies the spec'd escapes
(`%` -> `%25`, then `\r` -> `%0D`, then `\n` -> `%0A`; the `%`
replacement must happen first so the subsequent escapes are not
double-encoded), wrap every Finding rendered into a workflow
command with it, and pin both the helper and the end-to-end
single-line emission with two new regression tests.

Caught by gemini-code-assist on PR #5604.

* [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>
2026-05-19 05:56:56 -07:00
alkinun
f747108212
studio: extract tool-call XML parser into a reusable helper module (#5583)
Move the inline tool-call XML parser and stripper out of
studio/backend/core/inference/llama_cpp.py into a new
studio/backend/core/tool_healing.py so external inference servers
(llama-server wrappers, llama-swap, custom shims) can reuse the same
logic without importing the inference orchestrator, structlog, httpx,
or anything from torch / transformers / unsloth.

Closes #5502.

What this PR does:

- New file studio/backend/core/tool_healing.py contains the regex
  constants (_TOOL_CLOSED_PATS, _TOOL_ALL_PATS, _TC_JSON_START_RE,
  _TC_FUNC_START_RE, _TC_END_TAG_RE, _TC_FUNC_CLOSE_RE,
  _TC_PARAM_START_RE, _TC_PARAM_CLOSE_RE), parse_tool_calls_from_text,
  and strip_tool_call_markup. The regexes and function bodies are
  byte-for-byte the same as the previous inline implementation in
  llama_cpp.py; only the @staticmethod decorator and the closure-only
  `if not auto_heal_tool_calls: return text` short-circuit are dropped
  (the latter stays in the caller as a fast path when healing is off).
- studio/backend/core/inference/llama_cpp.py now imports the regexes
  and helpers from .tool_healing. LlamaCppBackend._parse_tool_calls_from_text
  becomes a one-line delegate; the _strip_tool_markup closure keeps the
  auto_heal_tool_calls fast path and delegates the work.
- Helper module imports cleanly without torch, transformers, structlog,
  httpx, or numpy. studio.backend.core itself is already stdlib-only
  at import time (lazy __getattr__), so `from
  studio.backend.core.tool_healing import parse_tool_calls_from_text,
  strip_tool_call_markup` is the lightweight import path issue #5502
  asked for.

No behaviour change for existing Studio paths. parse_tool_calls_from_text
and strip_tool_call_markup produce the same OpenAI-shape output the
old inline code produced for every input.

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-05-19 05:06:17 -07:00
alkinun
b01a1ba1c2
Fix GGUF multi-image chat handling (#5508)
Preserves per-turn OpenAI image_url content parts in the standard GGUF /v1/chat/completions path so multi-image chat history keeps each image attached to its original turn. Legacy top-level image_base64 is injected as a synthetic image_url part only when no message-level image exists. Tool use is disabled whenever any GGUF image is present. Fixes #5470.
2026-05-19 04:36:20 -07:00
Daniel Han
5ce4ab4d54
studio: emit one comma-chained --spec-type for CPU/Mac MTP path (#5575)
* studio: emit one comma-chained --spec-type for CPU/Mac MTP path

llama-server takes a single --spec-type whose value may be
comma-separated to chain implementations (e.g. ngram-mod,draft-mtp).
The CPU/Mac MTP branch in LlamaCppBackend.load_model was passing
--spec-type twice in the same invocation, which is not the documented
chaining mechanism and silently drops one of the two specs depending
on llama.cpp's argv handling.

Collapse the pair to --spec-type ngram-mod,{mtp_token} and update the
stale _extra_args_set_spec_type docstring that claimed llama-server
accumulates repeated --spec-type. Update the matching pass-through
fixture in test_llama_server_args.py.

* studio: align MTP ngram-mod knobs with llama.cpp upstream defaults

Two correctness fixes against the llama.cpp server README:

1. The CPU/Mac comma-chained branch was emitting
   --spec-ngram-mod-n-max 6 with --spec-ngram-mod-n-min 48, which is
   nonsensical (min > max). Per the upstream default the value is 64.

2. The standalone ngram-mod branch was emitting --spec-ngram-size-n,
   --draft-min, --draft-max. llama.cpp removed those arg aliases for
   ngram-mod (they live only on the ngram-simple / map families now);
   the correct knobs are --spec-ngram-mod-n-match / n-min / n-max.

Also refresh the inline comment block to point at the server README
rather than the older docs/speculative.md draft- aliases.
2026-05-19 03:16:05 -07:00
Daniel Han
4699c7e291
studio: engage draft-mtp on vision MTP GGUFs (drop incorrect vision gate) (#5560)
* studio: engage draft-mtp on vision MTP GGUFs

The draft-mtp auto-promotion in LlamaCppBackend.load_model was gated on
not effective_is_vision, and the spec-emit branch repeated the same
guard. Every Unsloth -MTP GGUF repo ships an mmproj projector, so
effective_is_vision was always True for those repos and the MTP speedup
silently never engaged out of the box.

llama.cpp #22673 explicitly states MTP is compatible with vision input.
The bundled b9204 server happily loads both: a manual run with
--mmproj ... --spec-type draft-mtp --spec-draft-n-max 6 logs
"loaded multimodal model" followed by
"adding speculative implementation 'draft-mtp'".

Drop the vision gate from both sites and rewrite the matching short
circuit in _already_in_target_state so reload checks reach the auto
promotion path on vision MTP loads. Add three regression tests covering
vision MTP match (auto and default), and non MTP vision repo unaffected.

Verified on a B200 with unsloth/Qwen3.6-35B-A3B-MTP-GGUF:UD-Q4_K_XL:
base decode 179.7 t/s vs MTP decode 253.8 t/s, draft acceptance 0.57,
1.41x speedup on a 255 token completion. mmproj still loads and image
input remains available.

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

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

* studio: prefer Qwen3.5 -MTP GGUF variants in default model lists

With the vision gate dropped in the previous commit, draft-mtp now
auto-engages on -MTP GGUF repos out of the box. Swap the four Qwen3.5
recommended entries in DEFAULT_MODELS_GGUF and DEFAULT_MODELS_STANDARD
to their -MTP-GGUF counterparts so new users get the speedup by default:

  unsloth/Qwen3.5-4B-GGUF        -> unsloth/Qwen3.5-4B-MTP-GGUF
  unsloth/Qwen3.5-9B-GGUF        -> unsloth/Qwen3.5-9B-MTP-GGUF
  unsloth/Qwen3.5-35B-A3B-GGUF   -> unsloth/Qwen3.5-35B-A3B-MTP-GGUF
  unsloth/Qwen3.5-0.8B-GGUF      -> unsloth/Qwen3.5-0.8B-MTP-GGUF

All four HF repos exist (HEAD 200) and ship the same UD-Q4_K_XL quant
layout as the non-MTP variants. Non-Qwen3.5 entries are untouched.

* bump version to 2026.5.4

Picks up the studio MTP vision-gate fix and the Qwen3.5 -MTP default
swap in this PR.

* studio: prefer Qwen3.6-35B-A3B-MTP-GGUF in default model lists

Same rationale as the previous Qwen3.5 swap. The Qwen3.6 MTP variant
exists at unsloth/Qwen3.6-35B-A3B-MTP-GGUF (HF HEAD 200) and now
auto-engages draft-mtp out of the box with the gate fix.

* studio: drop --spec-draft-n-max from 6 to 3 for draft-mtp

n=6 is too greedy: on Qwen3.6 the draft has to guess 6 tokens ahead
and acceptance crashes to ~0.45, leaving only ~14% throughput gain.

PR ggml-org/llama.cpp#22673's author benched n=3 at ~0.72 acceptance
and 2 to 3x speedup on the same Qwen3.6 family, and the README sample
command uses n=2 or n=3. Match that.

CPU/Mac branch already uses n=3, so this aligns both paths.

* studio: set --spec-draft-n-max back to 6 for draft-mtp on GPU

Reverts the n=3 tuning. n=6 is the original default; user-side comparisons
hold the larger draft window steady so the toggle (next commit) is the
primary on/off lever.

* studio: add Speculative Decoding toggle under Max Tokens

Adds a top-level kill switch (panel-switch under Max Tokens, mirroring
Auto-Healing Tool Calls) that forces the /load request's
speculative_type to "off" when disabled. The backend "off" branch in
LlamaCppBackend.load_model skips both the draft-mtp auto-promotion and
the spec-emit branch, so neither --spec-type draft-mtp nor
--spec-default reaches llama-server.

Wiring:

- chat-runtime-store: new speculativeDecodingEnabled bool, default
  true, persisted to localStorage under unsloth_speculative_decoding,
  plus a setSpeculativeDecodingEnabled setter.
- chat-settings-sheet: SpeculativeDecodingToggle rendered immediately
  beneath the Max Tokens slider for non-external models.
- use-chat-model-runtime: when speculativeDecodingEnabled is false,
  override speculative_type to "off" in the loadModel call so the
  switch wins over any pre-existing speculativeType state (including
  the existing per-model toggle in Model Settings).

Verified end to end on unsloth/Qwen3.6-35B-A3B-MTP-GGUF:UD-Q4_K_XL:
toggle ON emits --spec-type draft-mtp --spec-draft-n-max 6; toggle
OFF emits zero --spec-* flags on the same MTP GGUF.

* studio: relocate Speculative Decoding toggle into Model Settings

Move the toggle out from under Max Tokens and back into the Model
Settings section, directly beneath KV Cache Dtype, where the existing
Apply/Reset workflow already drives a reload on dirty. This way flipping
the switch in the UI actually picks up: the section becomes dirty,
Apply re-runs /load with the new speculative_type.

Drop the !currentModelIsMultimodal gate so vision MTP GGUFs can also
disable speculative decoding from the UI.

Switch the toggle's off-value from null to "off" so the backend's "off"
short-circuit fires for MTP models too (null normalises to None which
re-triggers the draft-mtp auto-promotion).

Tooltip now reads "Faster generation with 0% accuracy hit".

Remove the now-redundant speculativeDecodingEnabled bool + setter from
the runtime store and the load-time override in use-chat-model-runtime;
the toggle binds directly to speculativeType.

* studio: restore OOM/TIGHT badge on recommended GGUF rows

The recommended-list row passed vramStatus=null for any GGUF repo
because the existing useRecommendedModelVram hook reads safetensors
totals from HF model info, which GGUF-only repos do not expose. As a
result, an OOM Q-quant repo would render with only a "GGUF" badge and
no visual signal that nothing in it fits.

Add useGgufRecommendedFit: per repo, fetch the variant list via the
existing /api/models/gguf-variants endpoint, take the smallest
variant's size_bytes, and classify with the same 0.7*GPU + 0.7*RAM
thresholds as GgufVariantExpander. Session-scoped cache + in-flight
dedup so a repo is requested at most once.

Wire the result into the three GGUF row sites in pickers.tsx so OOM
and TIGHT badges show on the collapsed cards.

* Revert "studio: restore OOM/TIGHT badge on recommended GGUF rows"

This reverts commit 07793b1240df72b13e51d6dc15f63c4ee8c6cba9.

The new useGgufRecommendedFit hook was treating the symptom. PR #5561
identified the real root cause: useGpuInfo was calling /api/system
with plain fetch instead of authFetch, so the session-auth check
failed silently and gpu.available stayed false everywhere. With no
GPU info, every fit check (variant expander, recommended carousel)
fell back to "no signal" and dropped the OOM/TIGHT badges.

Reverting the over-engineered hook and applying the authFetch fix
in the next commit, which restores the existing badges with one line.

* chore: replace qwen suggested with MTP variant

* fix: restore GPU info auth for GGUF fit badges

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: imagineer99 <samleejackson0@gmail.com>
2026-05-18 08:42:55 -07:00