Compare commits

...
Sign in to create a new pull request.

62 commits

Author SHA1 Message Date
Daniel Han
5ec1208206 Fix consensus review findings on PR 5711
Round 3 of 3-Opus parallel review (2 reviewers HIGH on the persistence
chain, 2 HIGH on the routing chain, 1 HIGH on the test coverage gap).

HIGH fixes:
1. chat-runtime-store.ts: PERSISTED_INFERENCE_PARAM_KEYS extended from
   16 to 44 keys (28 extended samplers added). Before this, any value
   set on the Advanced Sampling sliders was lost on page reload because
   getChangedInferenceParams / getHydratedSettingsState iterate this
   list.
2. routes/chat_history.py: ChatInferenceSettings (extra="forbid")
   extended to mirror InferenceParams including fastMode + 28 new
   samplers. Without this every settings PUT containing any of those
   fields would 422.
3. routes/inference.py _build_openai_passthrough_body: was forwarding
   typical_p / mirostat / dynatemp but silently dropping dry_*, xtc_*,
   min_keep, ignore_eos, min_tokens, vLLM output knobs (skip/spaces
   special-tokens, include_stop_str_in_output, truncate_prompt_tokens),
   and llama.cpp instrumentation flags (n_keep, n_probs, cache_prompt,
   return_tokens, timings_per_token, post_sampling_probs). Now forwards
   all 18 to _build_passthrough_payload.
4. routes/inference.py _proxy_to_external_provider + external_provider.py
   stream_chat_completion: 20 extended kwargs are now plumbed through
   the route -> client -> OAI-compat body builder. Before this fix the
   chat-adapter computed top_a / vLLM output knobs / llama.cpp samplers
   on the frontend, sent them on the wire, and the route layer dropped
   them on the floor.
5. test_sampling_params_routing.py: extended
   test_chat_settings_payload_accepts_new_sampling_keys to round-trip
   every persisted field (was only 5). Added
   test_openrouter_forwards_top_a and test_vllm_forwards_output_shape_knobs
   to lock in the new wire forwarding.

MEDIUM fixes:
- providers.py: Mistral stop_max=4 (matches third-party shims; OAI docs
  publish no max but every consumer caps at 4).
- providers.py: Kimi body_omit now includes "presence_penalty" (Kimi
  k2.5/k2.6 chat schema lists temperature/top_p/max_tokens/stream/tools/
  tool_choice/thinking but not presence_penalty).
- external_provider.py: body_omit loop also pops the seed_field
  rename so a future provider with both `seed_field="random_seed"` and
  `body_omit=("seed",)` strips correctly. No current provider has both;
  defensive only.
- chat-adapter.ts: local-path parallel_tool_calls forwards only on
  explicit opt-out (matches the external-path stanza). Before this the
  field was sent on every chat from every existing local user.
- chat-settings-sheet.tsx: service tier Select now clamps the displayed
  value to a legal option for the active provider (e.g. "priority"
  saved on OpenAI, then user switches to Anthropic which only allows
  auto/standard_only -> Radix Select was showing a blank trigger).

LOW fixes:
- Em-dash cleanup: 7 em-dashes removed from provider-capabilities.ts /
  runtime.ts / chat-settings-sheet.tsx / test_sampling_params_routing.py
  per project rules.

Tests: 397/397 backend pass (sampling routing 69 plus anthropic /
openai / gemini / llama-server suites). Frontend tsc + vite build clean.
2026-05-27 16:32:21 +00:00
Daniel Han
1add4dbd0e Tighten comments across PR 5711 (no behaviour change)
Audited every comment added by this PR; condensed multi-paragraph
docstrings and inline blocks to 1-2 lines where the WHY survives.

Files touched: 6 backend + 7 frontend. Net -148 lines.
- external_provider.py: -80 (docstring + stop_sequences + compaction)
- chat-settings-sheet.tsx: -70 (InfoHint tooltips + section headers)
- routes/inference.py: -54 (parallel_tool_calls cap comments)
- provider-capabilities.ts: -108 (stop-cap table, gemini bucket,
  service_tier resolver, max-output cap header)
- chat-adapter.ts: -27 (sampling forwarding stanza)
- providers.py: -24 (kimi reasoning class, deepseek aliases)
- llama_cpp.py: -24 (payload builder shared header)
- models/inference.py: -28 (Pydantic Field descriptions)
- chat-settings-storage.ts: -23 (nullable handling comments)
- stop-sequences-input.tsx: -23 (JSDoc + chip-key + draft-commit)
- anthropic_compat.py: -9 (serial tool-call gate)
- types/runtime.ts: -12 (DRY JSDoc, null convention header)
- types/api.ts: -6 (service_tier JSDoc)

Tightening rules applied:
- Removed em-dashes everywhere a comment was rewritten.
- Kept every external doc URL (Anthropic, OpenAI, vLLM, llama.cpp,
  Ollama, OpenRouter, Mistral, DeepSeek, Gemini, Kimi).
- Collapsed "silently dropped on unsupported routes" prose since the
  bucket-comment at the top of each capability table already states it.
- Removed restated rationale prose in comment blocks where the field
  name plus the master rule already encodes the WHY.

393/393 backend tests pass (test_sampling_params_routing 65, plus
anthropic / openai / gemini / llama-server suites). Frontend tsc +
vite build clean.
2026-05-27 14:32:11 +00:00
Daniel Han
0cf2097a81 Merge remote-tracking branch 'origin/feat/expose-sampling-params-core' into pr-5711-head 2026-05-27 14:14:31 +00:00
Daniel Han
6112ca4ecc Merge main into PR #5711: resolve MCP-server import conflict
#5750 added remote MCP server support, which conflicted with our
import block in chat-settings-sheet.tsx. Kept both branches' imports
(MCP dialog + servers API from main, ServiceTier + Input from this PR).

393/393 backend tests pass; frontend type-check + vite build clean.
2026-05-27 14:12:59 +00:00
pre-commit-ci[bot]
80bf160b51 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-27 13:30:02 +00:00
Daniel Han
afed5fb791 Merge main into PR #5711: resolve Gemini-provider conflicts
Conflicts came from #5720 (native Gemini provider). All resolved
keeping both branches' functionality:

- provider-capabilities.ts: gemini bucket now uses #5720's narrow
  capability shape (temperature/topP/topK/presencePenalty true) plus
  the 27 extended-sampler fields from this PR (all false on gemini
  since Google's API doesn't accept them). stop=true added so the new
  generationConfig.stopSequences forwarding lights up the UI.
- chat-adapter.ts: kept all 27-field forwarding from this PR; used
  the tighter comments from main.
- routes/inference.py: pass both this PR's sampling kwargs
  (frequency_penalty/seed/stop/service_tier/parallel_tool_calls) and
  main's tools/tool_choice through to stream_chat_completion.
- external_provider.py: same. Every dispatcher (anthropic/openai/
  gemini) now takes both branches' new args. Added stop forwarding to
  _stream_gemini as generationConfig.stopSequences (capped at 5 per
  native API docs); updated test_gemini_stop_sequences_capped_to_5
  to assert the native shape instead of the OAI-compat shape.

256/256 backend tests pass (test_sampling_params_routing 65 +
anthropic/openai/gemini integration suites 191); frontend type-check
plus vite build clean.
2026-05-27 13:29:28 +00:00
Daniel Han
319a95796c Surface extended sampler knobs in chat settings panel (PR #5711)
Adds an "Advanced Sampling" collapsible section under the existing
Sampling block. Renders the 27 extended sampler fields plumbed earlier
in this PR (typical_p, top_n_sigma, mirostat family, dynatemp, top_a,
DRY chain, XTC, min_keep, ignore_eos, min_tokens, vLLM output knobs,
truncate_prompt_tokens, n_keep, n_probs, cache_prompt, llama.cpp
debug flags) using the same ParamSlider / Switch primitives as the
core knobs.

Each control is gated on the corresponding providerCapabilities flag
so the section only appears for backends that accept the knob (local
llama_cpp / custom: full set; vLLM: OAI subset + 4 output knobs;
Ollama: dropped per the OAI translator; OpenRouter: top_a only;
SaaS providers: section hidden entirely). The DRY chain's 3 child
fields and the XTC threshold are additionally hidden until their
master switch is non-zero, matching the upstream skip-when-disabled
rule the chat-adapter already enforces.

Nullable-number fields render at their upstream-disabled sentinel
(1.0 for typ_p, -1 for top_n_sigma, 0 for repeat_last_n, etc.) and
collapse back to null on the wire when the user moves them to that
sentinel, so the adapter omits the field entirely. Nullable booleans
render at the upstream default and only forward the non-default value
(cachePrompt / skipSpecialTokens / spacesBetweenSpecialTokens default
on and store null when on; ignoreEos / returnTokens / timingsPerToken
/ postSamplingProbs / includeStopStrInOutput default off and store
null when off).
2026-05-27 12:14:06 +00:00
Daniel Han
b60b0740c2 Tighten comments across PR 5711 (no behaviour change)
Comments-only pass. Drops verbose docstrings to single-line form,
removes repetitive "null = unset" / "Local only" tails (already
encoded by the type signature and capability map), keeps every
authoritative source URL but cuts surrounding prose, and removes
fully-redundant per-field comments where the field name already
says what the comment says.

Touches:
  - types/runtime.ts (InferenceParams)
  - types/api.ts (OpenAIChatCompletionsRequest wire shape)
  - provider-capabilities.ts (ProviderCapabilities interface + bucket
    inline blocks + per-model resolvers + reasoning helpers)
  - api/chat-adapter.ts (external + local forwarding stanzas)
  - backend models/inference.py (Field descriptions)
  - backend llama_cpp.py (3rd payload-builder inline comments)
  - backend routes/inference.py (_build_passthrough_payload)
  - backend external_provider.py (4.7 sampling-removed header,
    _is_openai_family_cloud docstring)

Net 508 lines deleted across 8 files; 65/65 sampling_params_routing
tests still pass; frontend tsc clean.
2026-05-27 11:14:45 +00:00
pre-commit-ci[bot]
eaaf7142f6 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-27 07:41:40 +00:00
Daniel Han
64328962d0 Expand local-backend coverage further: 10 more knobs from vLLM + llama.cpp live docs (PR #5711)
Round 4 expansion driven by direct fetches of the canonical
SamplingParams + server README pages cited in the user's request.
Adds ten more knobs the docs explicitly support but the panel doesn't
surface yet:

Knob (wire name)              llama.cpp  vLLM   Ollama   Source
----------------------------- ---------- ------ -------- ----------------
skip_special_tokens           no         yes    no       vLLM SamplingParams
spaces_between_special_tokens no         yes    no       vLLM SamplingParams
include_stop_str_in_output    no         yes    no       vLLM SamplingParams
truncate_prompt_tokens        no         yes    no       vLLM SamplingParams
n_keep                        yes        no     no       llama.cpp README
n_probs                       yes        no     no       llama.cpp README
cache_prompt                  yes        no     no       llama.cpp README
return_tokens                 yes        no     no       llama.cpp README
timings_per_token             yes        no     no       llama.cpp README
post_sampling_probs           yes        no     no       llama.cpp README

Backend rationale:
  - vLLM's documented SamplingParams class at
    https://docs.vllm.ai/en/latest/api/vllm/sampling_params/ lists
    skip_special_tokens (default True), spaces_between_special_tokens
    (True), include_stop_str_in_output (False), truncate_prompt_tokens
    (None). All four are vLLM-only; llama-server's README does not
    document them and Ollama's openai/openai.go translator does not
    forward them.
  - llama-server's README at
    https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md
    lists n_keep, n_probs, cache_prompt, return_tokens, timings_per_token
    and post_sampling_probs as documented per-request fields. vLLM's
    SamplingParams has no analog, and Ollama's OAI translator drops them.

Capability matrix:
  LLAMA_CPP_CAPABILITIES: 6 llama-only true + 4 vLLM-only false.
  VLLM_CAPABILITIES:       4 vLLM-only true + 6 llama-only false.
  OLLAMA_CAPABILITIES:     all 10 off (OAI translator drops all of them).
  Every other bucket:      all 10 off.

Skip-when-default rules (mirror upstream defaults):
  skip_special_tokens / spaces_between_special_tokens / cache_prompt:
    default true upstream — forward only when explicitly false.
  include_stop_str_in_output / return_tokens / timings_per_token /
    post_sampling_probs: default false — forward only when true.
  truncate_prompt_tokens / n_probs: 0 / null = unset — forward when > 0.
  n_keep: accepts -1 for "keep all", so the gate is value != 0.

Frontend:
  - ProviderCapabilities interface +10 flags.
  - InferenceParams +10 nullable fields (3 numeric + 7 boolean), all
    null in DEFAULT_INFERENCE_PARAMS.
  - OpenAIChatCompletionsRequest wire shape +10 optional fields.
  - chat-adapter forwards each in both the external (capability-aware)
    and local (capability-bypass) branches.
  - chat-settings-storage adds the 3 numeric keys to the existing
    nullable-number loop and 7 boolean keys to a new nullable-boolean
    loop (alongside ignoreEos).

Backend:
  - ChatCompletionRequest +10 Optional Fields with pydantic bounds
    (truncate_prompt_tokens ge=1, n_probs ge=0; booleans unbounded;
    n_keep accepts -1 so no lower bound).
  - llama_cpp.py three payload builders (generate_chat_stream + the
    tool-loop payload block + the final-pass stream_payload) each
    accept and forward the 10 new kwargs.
  - routes/inference.py _build_passthrough_payload accepts and forwards
    the 10; both per-request call sites (lines ~2591, ~2790) thread
    them from the request payload into the llama_cpp methods.

Test: test_local_passthrough_forwards_vllm_output_and_llama_cpp_
  instrumentation round-trips all 10 fields with explicit values
  matching each backend's upstream default and confirms each is absent
  from the body when unset.

65/65 sampling_params_routing tests pass; frontend tsc clean.

Total local-backend knob coverage now (this PR):
  Standard:    temperature, top_p, top_k, min_p, repetition_penalty,
               presence_penalty, frequency_penalty, seed, stop,
               parallel_tool_calls (10)
  llama.cpp:   typical_p, top_n_sigma, repeat_last_n, dynatemp_range,
               dynatemp_exponent, mirostat, mirostat_tau, mirostat_eta,
               dry_multiplier, dry_base, dry_allowed_length,
               dry_penalty_last_n, xtc_probability, xtc_threshold,
               min_keep, ignore_eos, min_tokens, n_keep, n_probs,
               cache_prompt, return_tokens, timings_per_token,
               post_sampling_probs (23)
  vLLM-extra:  ignore_eos, min_tokens, skip_special_tokens,
               spaces_between_special_tokens, include_stop_str_in_output,
               truncate_prompt_tokens (6)
  OpenRouter:  top_a (1)

Deferred for future PRs (require array / object field shape):
  - llama.cpp DRY sequence_breakers (string array)
  - llama.cpp samplers ordering (string array)
  - llama.cpp / vLLM logit_bias (dict)
  - llama.cpp grammar (string) + json_schema (object)
  - vLLM guided_json / guided_regex / guided_choice / guided_grammar
  - vLLM allowed_token_ids / bad_words / stop_token_ids (int / str arrays)
  - OpenAI / Ollama logprobs + top_logprobs (bool + int pairing)
  - n / best_of (need SSE multi-choice handling first)
2026-05-27 07:41:25 +00:00
Daniel Han
3674e11f07 Expand local-backend sampler coverage: DRY + XTC + min_keep + ignore_eos + min_tokens (PR #5711)
Round 3 expansion driven by direct fetches of the llama.cpp server README,
vLLM's SamplingParams source, and Ollama's openai.go OAI translator.
Adds nine new sampling/control knobs with per-backend capability gating:

Knob (wire name)       llama.cpp  vLLM   Ollama   Source
---------------------- ---------- ------ -------- -----------------------
dry_multiplier         yes        no     no       llama.cpp README
dry_base               yes        no     no       llama.cpp README
dry_allowed_length     yes        no     no       llama.cpp README
dry_penalty_last_n     yes        no     no       llama.cpp README
xtc_probability        yes        no     no       llama.cpp README
xtc_threshold          yes        no     no       llama.cpp README
min_keep               yes        no     no       llama.cpp README
ignore_eos             yes        yes    no       llama.cpp + vLLM SamplingParams
min_tokens             yes        yes    no       llama.cpp + vLLM SamplingParams

Backend-side rationale:
  - llama.cpp: full chain documented at
    https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md
  - vLLM: SamplingParams source confirms ignore_eos + min_tokens; the
    other seven have no field in
    https://github.com/vllm-project/vllm/blob/main/vllm/sampling_params.py
  - Ollama: openai/openai.go FromChatRequest copies only the OpenAI
    subset (temp/top_p/seed/freq/pres/max_tokens/logprobs/topLogprobs/
    response_format/reasoning_effort) on the /v1/chat/completions path
    Studio uses. All nine new knobs are silently dropped, so the
    OLLAMA_CAPABILITIES bucket keeps them off.

Frontend:
  - ProviderCapabilities interface gains 9 boolean flags.
  - InferenceParams gains 9 nullable fields (8 numeric + ignoreEos
    boolean), all defaulting to null in DEFAULT_INFERENCE_PARAMS.
  - OpenAIChatCompletionsRequest wire shape gains 9 optional fields
    with doc comments.
  - LLAMA_CPP_CAPABILITIES: all 9 on. VLLM_CAPABILITIES: 2 on
    (ignoreEos + minTokens) via inheritance, 7 off via explicit
    override. OLLAMA_CAPABILITIES: all 9 off (inherits + overrides
    ignoreEos/minTokens). Every other bucket (openai cloud / chat,
    anthropic, gemini, mistral, kimi, deepseek, openrouter) gets all
    9 off explicitly.
  - chat-adapter.ts gates each knob in both the external (capability-
    aware) and local (unconditional-when-meaningful) branches.
    Skip-when-default rules:
      dry_multiplier > 0 unlocks the 4-field DRY chain
      xtc_probability > 0 unlocks the 2-field XTC chain
      min_keep > 0, min_tokens > 0 forward only when set higher than 0
      ignore_eos forwards only when explicitly true
  - chat-settings-storage.ts persists all 9 keys (8 numeric in the
    existing nullable-number loop, ignoreEos with its own boolean
    handler).

Backend:
  - ChatCompletionRequest gains 9 Optional Field declarations with
    pydantic ge/le bounds (dry_multiplier ge=0; dry_base ge=1; xtc_*
    ge=0 le=1; min_keep / min_tokens / dry_allowed_length ge=0).
  - llama_cpp.py: three payload builders (generate_chat_stream + the
    two payload-construction blocks inside the tool-loop stream) each
    accept the 9 new kwargs and forward via `if x is not None`.
  - routes/inference.py: _build_passthrough_payload accepts the 9 new
    kwargs and forwards into the body. Two call sites that thread
    sampler params from the request payload (lines 2581, 2771) are
    extended to forward the 9 new fields.

Test:
  - test_local_passthrough_forwards_dry_xtc_min_keep_eos_min_tokens
    round-trips all 9 fields through _build_passthrough_payload and
    confirms each is absent when unset (so llama-server / vLLM apply
    their own defaults).

64/64 sampling_params_routing tests pass; frontend tsc clean.

Deferred for future PRs (require array / object field shape):
  - llama.cpp DRY sequence_breakers (string array)
  - llama.cpp samplers ordering (string array)
  - llama.cpp / vLLM logit_bias (dict)
  - llama.cpp n_probs + OpenAI logprobs/top_logprobs
  - llama.cpp grammar (string) + json_schema (object)
  - vLLM guided_json / guided_regex / guided_choice / guided_grammar
  - vLLM allowed_token_ids / bad_words / stop_token_ids
2026-05-27 07:04:29 +00:00
Daniel Han
c22f6e48ff Apply round-2 audit fixes: per-model OpenAI caps + o-series effort + Ollama bucket (PR #5711)
Second 5-Opus reviewer round. Applying high-confidence fixes; speculative
items (gpt-5.5-pro effort restriction, o3 image_generation gating,
o-series parallel_tool_calls per-model, gpt-5.x new model prefixes,
Anthropic fast-mode + Priority exclusion UI gate, Gemini service_tier,
Kimi k2.5 toggleable thinking) deferred to follow-up because they need
type-system changes, more verification, or backend wire work.

OpenAI max-output caps — replace the 3-line table with one driven by
direct dev.openai.com per-model fetches (cross-checked against the Azure
Foundry reasoning table):

  - gpt-5.4 / gpt-5.4-pro / gpt-5.4-mini / gpt-5.4-nano: 65536 -> 128000
    (https://developers.openai.com/api/docs/models/gpt-5.4 "128,000 max
    output tokens"; Azure table same).
  - gpt-5.3-codex: 16384 -> 128000
    (https://developers.openai.com/api/docs/models/gpt-5.3-codex).
  - gpt-5 / gpt-5.1 / gpt-5.2: 32k default -> 128000
    (https://developers.openai.com/api/docs/models/gpt-5.2 confirms
    128k; Azure table extends to gpt-5/5.1).
  - gpt-5.3-chat-latest and gpt-5.1-chat keep 16384 (chat-class
    variants per Azure context table row).
  - o1 / o3 / o3-mini / o3-pro / o4-mini / codex-mini: 32k default ->
    100000 (https://developers.openai.com/api/docs/models/o3 "100,000
    max output tokens"; Azure o-series table same).

Implementation: list the two 16k chat-latest ids first so the broader
`gpt-5` 128k entry doesn't shadow them.

OpenAI reasoning_effort levels:

  - gpt-5.3-codex: drop "none" from levels + flip supportsOff to false.
    Dev page lists the enum as low/medium/high/xhigh only — `none` is
    not in the codex variant.
  - o-series bucket: change prefix from ["o3"] to
    ["o1","o3","o4","codex-mini"]. Previously o1 / o4-mini / codex-mini
    fell into NO_REASONING_CAPS so the panel HID the effort slider for
    them — real UX regression for users on those ids. Azure o-series
    table confirms all four accept low/medium/high reasoning_effort.

DeepSeek default_models:

  - Add deepseek-v4-pro + deepseek-v4-flash alongside the legacy
    deepseek-chat / deepseek-reasoner aliases. The latter retire on
    2026-07-24 per https://api-docs.deepseek.com/updates; surfacing
    both lets the picker keep working on cutover.

Local backend bucket split (Ollama-stricter):

  - Splits the round-1 VLLM_OLLAMA_CAPABILITIES into a vLLM-specific
    bucket (keeps top_k / min_p / repetition_penalty / seed on; vLLM's
    SamplingParams supports all four) and an Ollama-specific bucket
    that ALSO hides top_k / min_p / repetition_penalty. Ollama's OAI
    translator (ollama/openai/openai.go FromChatRequest) only copies
    the documented OpenAI subset on the /v1/chat/completions path that
    Studio uses; the three knobs are silently dropped even though
    native /api/chat would forward them via `options`. Hiding them is
    the smaller fix vs adding a backend /api/chat rewrite path.

Reviewer claims verified wrong, skipped:

  - _ANTHROPIC_NEW_CODE_EXEC_PREFIXES already lists opus-4-7, opus-4-6,
    sonnet-4-6 (external_provider.py:337-339). No-op.
  - Mistral `seed` already renamed to `random_seed` by backend at
    external_provider.py:772. No-op.
  - OpenRouter `isOpenRouterMandatoryReasoningModel` uses `Set.has()`
    exact match, not prefix match, so deepseek/deepseek-r1-distill-*
    cannot accidentally hit the always-on guard. No-op.

Tests: 63/63 sampling_params_routing tests pass; frontend tsc clean.
2026-05-27 06:49:15 +00:00
Daniel Han
0234bef047 Apply 5-reviewer audit fixes to per-provider capability buckets (PR #5711)
Five independent reviewers cross-checked every provider's per-model
sampling-knob exposure against live docs (OpenAI, Anthropic, Gemini,
DeepSeek, Kimi, Mistral, OpenRouter, llama.cpp, vLLM, Ollama).
Applying the high-confidence drift fixes here; speculative items (pro
model effort restrictions, gpt-5.3 cap, OpenAI verbosity / o-series
output cap, Gemini topK / service_tier) are deferred to a follow-up
because they need backend wire changes or unverified doc claims.

Anthropic:
  - Move claude-opus-4-6 from the 64k group into the 128k group (live
    legacy table shows Opus 4.6 Max output = 128k tokens).
    https://platform.claude.com/docs/en/about-claude/models/overview
  - Add claude-sonnet-4 to the 64k group (was falling through to 32k
    default; live legacy table shows Sonnet 4 Max output = 64k tokens).
  - Extend ANTHROPIC_REASONING_MODELS with legacy claude-opus-4-1 /
    claude-opus-4 / claude-sonnet-4 at none/low/medium/high (live
    legacy table marks Extended thinking = Yes for all three).

OpenAI:
  - Split the gpt-5/gpt-5.1/gpt-5.2 reasoning bucket. Per Azure docs
    footnote ^7^, "minimal is only supported with the original GPT-5
    reasoning models. minimal is not supported with gpt-5.1 or greater".
    gpt-5.1 / gpt-5.2 now get none/low/medium/high/xhigh with
    supportsOff=true; bare gpt-5 keeps minimal/low/medium/high
    supportsOff=false. Ordering puts gpt-5.1 / gpt-5.2 before gpt-5 in
    the find() loop so the longer prefix matches first.
    https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/reasoning

DeepSeek:
  - Hide `seed` and `parallel_tool_calls` in the deepseek capability
    bucket. Neither field is in the current /chat/completions schema
    (body fields: messages, model, thinking, max_tokens, response_format,
    stop, stream, stream_options, temperature, top_p, tools, tool_choice,
    logprobs, top_logprobs, user_id). Surfacing them in the UI would be
    the silent-drop UX the file header warns against.
    https://api-docs.deepseek.com/api/create-chat-completion

Mistral:
  - magistral-medium-latest / magistral-small-latest are NATIVE
    always-on reasoning models; injecting reasoning_effort returns 422
    upstream. Switch both to withEnableThinkingStyle({reasoningAlwaysOn:
    true}) instead of the old none/medium/high effort ladder.
  - mistral-small-latest / mistral-medium-latest / mistral-vibe-cli-latest
    expose the documented three-tier adjustable ladder
    (none/low/medium/high), not the truncated none/high pair that was
    here before. mistral-medium-latest was not handled at all and now
    sits in the same bucket as small.
    https://docs.mistral.ai/studio-api/conversations/reasoning
    https://mistral.ai/news/magistral

OpenRouter:
  - Drop google/gemini-pro-latest from OPENROUTER_MANDATORY_REASONING_
    MODELS; the gateway 404s the id today
    (https://openrouter.ai/google/gemini-pro-latest). Removing rather
    than re-pinning to a versioned id that may rotate again.

Local backends:
  - Split LOCAL_LLAMA_CAPABILITIES into LLAMA_CPP_CAPABILITIES (full
    chain — for llama_cpp + custom) and VLLM_OLLAMA_CAPABILITIES (OpenAI
    subset + top_k/min_p/repetition_penalty/seed, no extended samplers).
    vLLM's SamplingParams has no typical_p / top_n_sigma / repeat_last_n
    / dynatemp_* / mirostat* fields, and Ollama's OpenAI translator
    (ollama/openai/openai.go FromChatRequest) only copies the OpenAI
    subset. Surfacing the eight extra sliders for vllm / ollama was
    silent-drop UX.

Tests:
  - test_deepseek_payload_omits_seed_and_parallel_tool_calls: read the
    TS file as text and assert the bucket has seed:false and
    parallelToolCalls:false. Backend has no JS engine; this is the
    cheapest way to lock the wire-drop invariant.
  - 63/63 sampling_params_routing tests pass; frontend tsc clean.
2026-05-27 06:31:40 +00:00
Daniel Han
22111744a4 Narrow Anthropic 4.7 sampling-removed gate to Opus only (PR #5711)
The 4.7 generation only shipped Claude Opus 4.7; Sonnet stops at 4.6
and Haiku at 4.5 per
https://platform.claude.com/docs/en/about-claude/models/overview.
The earlier `^claude-(?:opus|sonnet|haiku)-4-7` regex on both the
backend strip (_ANTHROPIC_4_7_SAMPLING_REMOVED in external_provider.py)
and the frontend mirror (ANTHROPIC_4_7_SAMPLING_REMOVED_REGEX in
provider-capabilities.ts) would have pre-emptively hidden temperature
/ top_p / top_k for any future claude-sonnet-4-7 or claude-haiku-4-7
id, even though Anthropic has explicitly not extended the sampling
removal beyond Opus. Tighten both regexes to `^claude-opus-4-7(?:[-.]|$)`
and update the routing-test pin so claude-sonnet-4-7 and claude-haiku-4-7
are in `should_not_match`. If those ids ever ship and adopt the same
removal, widening the regex is one-line.
2026-05-27 05:43:38 +00:00
pre-commit-ci[bot]
6b300699bf [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-26 14:36:42 +00:00
Daniel Han
60085606a7 Merge branch 'main' into feat/expose-sampling-params-core
Single conflict on studio/backend/core/inference/external_provider.py:
main added `previous_response_id` plumbing for OpenAI Responses chaining
in the same body-builder block PR 5711 uses for service_tier /
parallel_tool_calls. Kept both sets — service_tier+parallel_tool_calls
write first, then previous_response_id appends. No behavioural change
to either feature.

163 backend routing tests still pass; frontend tsc clean.
2026-05-26 14:36:15 +00:00
Daniel Han
facdff9ad7 Add extended llama.cpp samplers + OpenRouter top_a (PR #5711)
Cross-checked every supported sampling field against each provider's
live docs + LiteLLM's drop_params surface + the llama.cpp server
README. Pulled in the most-asked-for samplers that the PR was missing.

New ProviderCapabilities flags (default false on every SaaS provider
since none accept these):
  - typicalP            (already shipped one commit prior)
  - topNSigma           llama.cpp `top_n_sigma`
  - repeatLastN         llama.cpp `repeat_last_n` (paired w/ repeat_penalty)
  - dynatempRange       llama.cpp `dynatemp_range`
  - dynatempExponent    llama.cpp `dynatemp_exponent`
  - mirostat            llama.cpp `mirostat` mode (0/1/2)
  - mirostatTau         llama.cpp `mirostat_tau`
  - mirostatEta         llama.cpp `mirostat_eta`
  - topA                OpenRouter `top_a` (alternate dynamic-top-P)

Capability bucketing split: ALL_SUPPORTED retired in favor of
  - LOCAL_LLAMA_CAPABILITIES  -> custom / vllm / ollama / llama_cpp
    (full llama.cpp sampler chain, top_a off — not a llama.cpp field)
  - OPENROUTER_CAPABILITIES   -> openrouter
    (gateway's documented set incl. top_a, llama.cpp-only knobs off
     because OpenRouter docs don't list them and they'd be silently
     dropped on most underlying routes)

InferenceParams gains 8 nullable-number fields (mirroring `seed`'s
"null = unset, finite-number = forwarded" shape). DEFAULT_INFERENCE_PARAMS
defaults each to null. Persistence handler in chat-settings-storage
mirrors typicalP's nullable-float handling for all 8.

Backend:
  - 8 new ChatCompletionRequest fields with appropriate `ge`/`le`
    validators (mirostat 0..2, ranges 0.0..1.0 where applicable).
  - llama_cpp.py: signatures + payload forwarding extended on all
    three builders (chat-completion, agentic tool-loop, final-pass)
    so the new fields survive the local tool-loop too. `is not None`
    gating so defaults (e.g. mirostat=0) reach the wire only when the
    caller explicitly opted in.
  - routes/inference.py: _build_passthrough_payload extends to the
    extended sampler chain; 3 call sites (generate_chat_completion,
    generate_chat_completion_with_tools, _build_passthrough_payload)
    forward each field from `payload.*`.

Frontend chat-adapter: external branch forwards only when capability
allows (so OpenRouter gets top_a but not mirostat, local gets mirostat
but not top_a); local branch forwards unconditionally when the value
is meaningful (e.g. mirostat != 0, dynatemp_range > 0).

Test pinning the new field round-trip through _build_passthrough_payload
added; full PR-touched suite now 163 passing (was 161).

References:
  - llama.cpp server params: https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md
  - OpenRouter params:       https://openrouter.ai/docs/api/reference/parameters
  - LiteLLM provider params: https://docs.litellm.ai/docs/completion/input
2026-05-26 14:34:32 +00:00
Daniel Han
a02320aa7b Add typical_p sampler (local) + DeepSeek-reasoner per-model gating (PR #5711)
Two follow-ups from a closer reading of each provider's published
sampling surface against llama.cpp's own server README.

typical_p (locally typical sampling, `typ_p` in the llama.cpp sampler
chain):
  - New ProviderCapabilities.typicalP flag; defaults false on every
    SaaS provider (none accept the field) and true only on the
    permissive local buckets (custom, vllm, ollama, llama_cpp,
    openrouter via ALL_SUPPORTED). InferenceParams.typicalP is
    nullable number (null = unset; 1.0 = llama-server default, also
    treated as no-op when forwarding).
  - Backend: new ChatCompletionRequest.typical_p Field (0.0..1.0).
    Threaded through all three llama_cpp.py payload builders
    (chat-completion, agentic tool-loop, final-pass) so the field
    survives the local tool-loop too. _build_passthrough_payload in
    routes/inference.py picks it up and only writes the body when
    the caller set a value; left absent it falls back to llama-server
    default. Three route call sites (generate_chat_completion,
    generate_chat_completion_with_tools, _build_passthrough_payload)
    forward payload.typical_p.
  - Frontend: chat-adapter forwards on both branches (external opt-in
    only when capability allows + value != 1; local forwards
    unconditionally when set and != 1). OpenAIChatCompletionsRequest
    grows a `typical_p?` field. Persisted via chat-settings-storage
    mirroring the seed nullable-float handler.
  - Test: pin _build_passthrough_payload's forward + absent behavior.

DeepSeek per-model gating:
  - deepseek-reasoner / deepseek-r1 silently ignore temperature, top_p,
    presence_penalty, frequency_penalty per
    https://api-docs.deepseek.com/guides/reasoning_model — mirror the
    OpenAI / Claude 4.7 per-model approach: getProviderCapabilities
    downshifts these ids to a stripped capability set so the panel
    does not offer knobs the upstream silently drops.

161+1 sampling-routing tests pass; frontend tsc clean.

Refs:
  - llama.cpp server params: https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md
  - DeepSeek reasoner restrictions: https://api-docs.deepseek.com/guides/reasoning_model
2026-05-26 14:12:40 +00:00
Daniel Han
c43c48a7e0 Merge branch 'main' into feat/expose-sampling-params-core
Resolve 5 conflicts where main added Anthropic Opus 4.6/4.7 fast-mode
support that touches the same signatures PR 5711 extended:

  studio/backend/core/inference/external_provider.py
    Keep both PR 5711 sampling fields (frequency_penalty, seed, stop,
    service_tier, parallel_tool_calls) and main's fast_mode in the
    stream signature, docstring, and _stream_anthropic call site.
  studio/backend/models/inference.py
    Append fast_mode Field alongside PR 5711's new ChatCompletionRequest
    fields; both flow through the existing dispatch.
  studio/backend/routes/inference.py
    Forward fast_mode and the PR 5711 sampling fields to the stream
    generator together.
  studio/frontend/src/features/chat/types/api.ts
    Add fast_mode? to OpenAIChatCompletionsRequest after the PR 5711
    field block.
  studio/frontend/src/features/chat/utils/chat-settings-storage.ts
    Persist fastMode alongside seed / stop / serviceTier /
    parallelToolCalls.

No semantic changes to either feature surface. 161 backend routing
tests still pass; frontend tsc clean.
2026-05-26 07:22:06 +00:00
Daniel Han
4f9125a5f9 Pin Claude 4.7 sampling-removed regex with backend test (PR #5711)
Guards against drift between the backend _ANTHROPIC_4_7_SAMPLING_REMOVED
regex and the frontend ANTHROPIC_4_7_SAMPLING_REMOVED_REGEX added in the
previous commit. If a future patch widens one without the other the panel
will either silently strip a knob the user moved or 400 on a knob the UI
should have hidden — both bad UX.

Test pins the canonical 4.7 id shapes (opus/sonnet/haiku) including dated
snapshots, and the non-4.7 ids that must NOT match (4-6, 4-5, 4-71,
future 5, gpt-4o, etc.). Sampling-params suite now 60 passing
(previously 59).
2026-05-26 05:48:17 +00:00
Daniel Han
643c0a88f1 Per-model OpenAI / Anthropic 4.7 sampling gating (PR #5711)
OpenAI gating was per-provider — the restrictive reasoning-class capability
applied to gpt-4o too, even though gpt-4o on /v1/responses still accepts
temperature / top_p / seed / frequency_penalty / presence_penalty. Anthropic
4.7 was the inverse: backend stripped temperature / top_p / top_k per-model
but the UI still showed the sliders, so moving a knob silently did nothing.

Split openai capabilities into OPENAI_REASONING_CAPABILITIES (current
restrictive set, used for gpt-5.x / o1 / o3 / o4) and OPENAI_CHAT_CAPABILITIES
(full sampling minus top_k and stop, used for gpt-4o and any non-reasoning id
from the registry). Mirror the backend _ANTHROPIC_4_7_SAMPLING_REMOVED regex
on the frontend so claude-(opus|sonnet|haiku)-4-7 hides temperature / top_p /
top_k in the panel instead of relying on backend strip. getProviderCapabilities
now takes an optional modelId so chat-settings-sheet and chat-adapter both
resolve the same per-model variant; no behavior change for unspecified modelId.

Verified live OpenAI / Anthropic docs:
  - GPT-5 temperature must equal 1: platform.openai.com/docs/guides/reasoning,
    community.openai.com/t/temperature-in-gpt-5-models/1337133
  - GPT-4o accepts full sampling on Responses: docs.aimlapi.com gpt-4o ref,
    OpenAI cookbook seed example
  - Claude 4.7 sampling removed (400 on any non-default temperature/top_p/
    top_k): platform.claude.com/docs/en/about-claude/models/whats-new-claude-4-7

160 backend routing tests still pass; frontend tsc clean.
2026-05-26 05:46:14 +00:00
Daniel Han
5d4ddd6b37 Revert "Surface OpenAI Responses service_tier='scale' (PR #5711)"
Round 19 added scale to /v1/responses based on the openai-python
SDK type, but round 20 reviewers (3/10 against) and the round 18
aggregator both noted that the live OpenAI Responses reference
limits Responses service tiers to auto/default/flex/priority. The
PR contract in the original description also lists scale only for
Chat Completions, not Responses. Studio routes OpenAI through
Responses, so forwarding scale risks a 400 from the upstream and
exposes a picker option the API does not accept.

Restore the conservative drop behaviour: only documented Responses
tiers reach the wire; legacy scale settings still validate (the
ServiceTier Literal and chat-settings storage allowlist keep it
for forward-compat).
2026-05-24 20:25:55 +00:00
pre-commit-ci[bot]
f7c11d8a0a [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 20:11:43 +00:00
Daniel Han
b961c6f2f7 Surface OpenAI Responses service_tier="scale" (PR #5711)
Round 19 reviewer consensus (3/10 plus an asymmetric-fix call-out
across rounds 8/9/12/14/17): the openai-python SDK ships
service_tier as Literal["auto","default","flex","scale","priority"]
on /v1/responses, and enterprise Scale Tier customers need to opt in
explicitly. Drop the defensive scale-filter on the backend and add
"scale" to the OpenAI picker option list so the field reaches the
wire when set. Other providers remain at auto/default per their docs.

Update the routing tests so `scale` lives in the forwarded-set fixture
and the dropped-set fixture only carries truly out-of-enum values
(Anthropic-only `standard_only`, typos, empty string).
2026-05-24 20:09:17 +00:00
Daniel Han
07831d4c0c Shut down asyncgens in routing tests to silence cleanup warnings (PR #5711)
Round 18 reviewers (and earlier) noted CI noise from the routing test
helper: `_drive(coro)` ran a fresh event loop but never closed it or
called `shutdown_asyncgens`, so the MockTransport-backed httpx async
generators in the providers were finalised by GC in a later task and
emitted "Response.aiter_text.aclose was never awaited" / "Task was
destroyed but it is pending" warnings.

Explicitly close the loop after `run_until_complete`, running
`shutdown_asyncgens` first so the iterators finalise in this task.
Tests still pass and the warnings are gone.
2026-05-24 19:54:08 +00:00
Daniel Han
0be1e09421 Clean local stop list + cap Responses bridge parallel tool calls (PR #5711)
Round 17 reviewer consensus on two extensions of the round 16 cap:

1. Direct GGUF stop forwarding (3/10 + sibling findings) — every
   llama_cpp.py payload builder and routes/inference.py direct-GGUF
   call site pass `stop` through unfiltered, while the external
   provider helper and `_build_passthrough_payload` already strip
   empty / non-string entries. Add a shared `_clean_local_stop_list`
   helper in the route layer for the two callers, mirror the same
   inline filter in `llama_cpp.py`'s three payload builders. Stops
   `stop=["", "END"]` from a stale client 400'ing llama-server.

2. Responses bridge tool-call cap (3/10 streaming + 1/10 non-streaming)
   — `_responses_stream` iterated every streamed `delta.tool_calls`
   index and `_responses_non_streaming` translated every returned
   `message.tool_calls` entry, even when `parallel_tool_calls=false`.
   Latch the first tool-call index in the streaming bridge and drop
   subsequent siblings; cap to one in the non-streaming bridge.
   Matches the GGUF agentic-loop / Anthropic-passthrough caps.

Local-passthrough OpenAI paths (verbatim SSE / verbatim JSON) are
left alone because the contract is "raw upstream forwarding"; clients
calling /v1/chat/completions through Studio directly should still see
llama-server's native output.
2026-05-24 19:39:37 +00:00
Daniel Han
5218a01d14 Apply parallel_tool_calls cap to Anthropic passthrough + safetensors path (PR #5711)
Round 16 reviewer consensus extended the round 12c asymmetric-fix:
the GGUF agentic loop capped tool_calls to one when the caller opted
out, but every other Studio-internal path that emits tool calls from
llama-server output skipped the same guard.

Mirror the cap in three places that have full ownership of the
emitted list (passthrough verbatim paths are out of scope):

1. `AnthropicPassthroughEmitter` now takes `parallel_tool_calls` and
   silently drops streamed `delta.tool_calls` entries beyond the
   first index. Wired from `_anthropic_passthrough_stream`.
2. `_anthropic_passthrough_non_streaming` truncates the upstream
   `message.tool_calls` list before producing `tool_use` blocks.
3. `run_safetensors_tool_loop` truncates the parsed tool_calls list
   before appending the assistant message and executing tools.
   `InferenceOrchestrator.generate_chat_completion_with_tools` and
   the safetensors route now thread `parallel_tool_calls` through.

Also harden `_build_passthrough_payload` to strip empty / non-string
`stop` entries before forwarding to llama-server, matching the
`_normalize_stop_for_provider` shape the external-provider helper
already enforces.

Test pins the AnthropicPassthroughEmitter serial-tool-call gate.
2026-05-24 18:46:07 +00:00
Daniel Han
4c3be18d00 Preserve explicit serviceTier="auto" through the settings picker (PR #5711)
Round 13 P1 finding: the Service tier picker rendered `null` as the
displayed `auto` and converted any explicit `auto` selection back to
`null`, so the chat-adapter's truthy guard then omitted `service_tier`
on the wire. For Anthropic the docs distinguish:

  - omitting `service_tier`     -> provider default
  - `service_tier="auto"`       -> opts into Priority Tier when available
  - `service_tier="standard_only"` -> opts out

Drop the auto -> null conversion so the user's explicit pick reaches
the adapter and the wire reflects it. `null` still means "never set"
and falls through to the provider default; the existing serviceTier
allowlist already includes "auto" everywhere it matters.
2026-05-24 17:46:33 +00:00
Daniel Han
67e371934c Enforce parallel_tool_calls=False client-side on local GGUF (PR #5711)
Two related findings from round 12 reviewers:

1. The local GGUF tool loop in `generate_chat_completion_with_tools`
   iterates every entry of `tool_calls` returned by llama-server, even
   when the caller explicitly opted out of parallel tool calls. The
   `parallel_tool_calls` flag is forwarded to llama-server, but llama
   .cpp does not enforce it on every jinja template
   (https://github.com/ggml-org/llama.cpp/issues/22043), so a model
   that ignores the flag still ran multiple tools per turn. Cap
   `tool_calls` to the first entry when the flag is False so the
   client-side contract holds regardless of upstream behavior.

2. llama-server documents `parallel_tool_calls` as defaulting to FALSE
   (https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md),
   so the previous chat-adapter shape (forward only on explicit false)
   meant the UI's default-on state could never enable parallel tool
   calls there. Always forward the user's preference on the local
   path so the toggle actually does what it says. External providers
   default to true everywhere, so the external branch is unchanged.

Test pins the GGUF tool-loop cap by source-level assertion (the loop
itself is integration-only).
2026-05-24 17:35:05 +00:00
Daniel Han
737c5ad0e0 Allow whitespace stop sequences from the chips editor (PR #5711)
Round 11 P2 finding: the stop-sequence chips input rejected any draft
that strip to empty, which silently dropped pasted whitespace stops
like `"\n\n"` for blank-line halts. Local llama-server and OpenAI-
compat backends accept those; the Anthropic helper independently
filters whitespace entries before they hit the wire, so allowing them
in the UI cannot turn into a 400.

Drop the .trim() gate; reject only the truly empty draft. Single-line
Input behaviour is unchanged for the common typed-letters path.
2026-05-24 17:19:50 +00:00
Daniel Han
48df6a98c8 Forward disable_parallel_tool_use through Anthropic client-tool passthrough (PR #5711)
Round 11 reviewer consensus (10/10): the `disable_parallel_tool_use`
translation added in round 11b reached the Anthropic-compat server-tool
GGUF loop but not the analogous client-tool passthrough branch. A
client sending `/v1/messages` with custom tools plus
`tool_choice: {"type":"auto","disable_parallel_tool_use":true}` therefore
took the passthrough branch with the opt-out silently dropped on the
llama-server `/v1/chat/completions` body.

Thread the translated `anthropic_parallel_tool_calls` value through
`_anthropic_passthrough_stream` and `_anthropic_passthrough_non_streaming`
into the shared `_build_passthrough_payload`, which already knows the
field. Test pins both helpers' signatures and that the field reaches
the body via the payload builder.
2026-05-24 17:18:33 +00:00
Daniel Han
0c68f79ebd Merge remote-tracking branch 'origin/main' into pr-5711-head 2026-05-24 16:55:29 +00:00
Daniel Han
d3ae9142a5 Gemini stop cap is 4, matching the OpenAI compat layer (PR #5711)
Gemini exposes its OpenAI-compatible endpoint at
https://generativelanguage.googleapis.com/v1beta/openai. Google's own
docs (https://ai.google.dev/gemini-api/docs/openai) list the supported
parameters and inherit OpenAI's 4-entry stop cap. Without an explicit
`stop_max=4` on the registry the default 16 leaks through and the
upstream silently drops the overflow.

Add the backend registry entry, mirror it in the frontend
`PROVIDER_STOP_MAX` map, and pin the cap with a focused unit test.
2026-05-24 16:39:49 +00:00
Daniel Han
9cd730130e Hide non-supported sampling controls for local safetensors (PR #5711)
The local non-external GGUF path (llama-server) accepts frequency_penalty,
seed, stop and parallel_tool_calls, but the safetensors / HF transformers
worker has no equivalent kwargs and silently drops them. Showing the
controls there has been confusing reviewers: the UI promises a knob that
does nothing.

Gate frequencyPenalty / seed / stop / parallelToolCalls on `isGguf` for
non-external local models so safetensors sessions only show controls the
backend actually honours. External-provider gating is unchanged.

Stale persisted values from a prior GGUF session are still sent on the
wire but the safetensors worker keeps absorbing them via **_unused, so
this is a presentation-only change with no behaviour difference.
2026-05-24 16:34:14 +00:00
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
Daniel Han
10ade237cd Hide Kimi seed and parallel_tool_calls controls (undocumented upstream)
Kimi's official Chat Completion schema at https://platform.kimi.ai/docs/api/chat does not list seed or parallel_tool_calls. Hide both controls so users are not offered settings the upstream may silently drop or 400 on. Frequency penalty, presence penalty, and stop sequences remain exposed because Kimi documents them with full ranges.
2026-05-24 15:21:10 +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
aa9d8e2180 ci: re-trigger after transient infra flake on Windows prebuilt / actions/checkout 2026-05-23 19:34:47 +00:00
Daniel Han
5316c29588 ci: re-trigger after transient GitHub API HTTP flake (checkout + ggml-org release fetch) 2026-05-23 18:35:18 +00:00
Daniel Han
febadebefa ci: re-trigger after transient actions/checkout git auth flake 2026-05-23 17:34:07 +00:00
Daniel Han
ffda6bbc71 Studio: persist new sampling keys through settings sanitizer
Codex P1: the runtime store added frequencyPenalty, seed, stop,
serviceTier, parallelToolCalls but the save/load path went through
sanitizeInferenceParams, which only whitelisted the older numeric set
plus systemPrompt / trustRemoteCode. The new keys were silently
stripped on save and dropped on reload.

Extend the whitelist:
- frequencyPenalty added to the numeric finite-number set.
- seed: integer or explicit null (null = "no seed field on the wire").
- stop: string array, capped at 4 entries per OpenAI's limit.
- serviceTier: nullable enum (auto/default/flex/priority/scale).
- parallelToolCalls: boolean.
2026-05-23 16:37:51 +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
22 changed files with 4652 additions and 313 deletions

View file

@ -404,12 +404,18 @@ class AnthropicPassthroughEmitter:
streaming response back to Anthropic format without executing anything. streaming response back to Anthropic format without executing anything.
""" """
def __init__(self) -> None: def __init__(self, *, parallel_tool_calls: Optional[bool] = None) -> None:
self.block_index: int = -1 self.block_index: int = -1
self._current_block_type: Optional[str] = None # "text" | "tool_use" | None self._current_block_type: Optional[str] = None # "text" | "tool_use" | None
self._tool_call_states: dict = {} # delta index -> {block_index, id, name} self._tool_call_states: dict = {} # delta index -> {block_index, id, name}
self._usage: dict = {} self._usage: dict = {}
self._stop_reason: str = "end_turn" self._stop_reason: str = "end_turn"
# parallel_tool_calls=False (Anthropic
# tool_choice.disable_parallel_tool_use=true): emit only the first
# tool-call index; llama.cpp's flag isn't enforced by every jinja
# template (ggml-org/llama.cpp#22043).
self._serial_tool_calls: bool = parallel_tool_calls is False
self._first_tool_call_idx: Optional[int] = None
def start(self, message_id: str, model: str) -> list[str]: def start(self, message_id: str, model: str) -> list[str]:
return [ return [
@ -470,6 +476,13 @@ class AnthropicPassthroughEmitter:
tool_calls = delta.get("tool_calls") or [] tool_calls = delta.get("tool_calls") or []
for tc in tool_calls: for tc in tool_calls:
tc_idx = tc.get("index", 0) tc_idx = tc.get("index", 0)
# Serial-tool-call gate: latch on the first index we see
# and silently drop deltas for any other.
if self._serial_tool_calls:
if self._first_tool_call_idx is None:
self._first_tool_call_idx = tc_idx
if tc_idx != self._first_tool_call_idx:
continue
fn = tc.get("function") or {} fn = tc.get("function") or {}
if tc_idx not in self._tool_call_states: if tc_idx not in self._tool_call_states:
# New tool call — close prior block, open tool_use block # New tool call — close prior block, open tool_use block

View file

@ -29,30 +29,62 @@ import structlog
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
# Claude 4.7 (Opus/Sonnet/Haiku) removed temperature, top_p, and top_k — def _normalize_stop_for_provider(
# the API returns 400 "<param> is deprecated for this model" if any of stop: Optional[Union[str, list[str]]],
# them is set to a non-default value. The "Sampling parameters removed" provider_info: dict[str, Any],
# section of the 4.7 release notes is the authoritative reference: ) -> Optional[Union[str, list[str]]]:
# https://platform.claude.com/docs/en/about-claude/models/whats-new-claude-4-7 """Apply per-provider stop_max / stop_max_bytes caps and dedup.
# 3.x and 4.5/4.6 still accept all three; match the 4-7 line strictly so
# the knobs keep working on earlier families. The trailing -4-7[-.]/EOL Returns None when nothing survives the filter so callers can omit
# anchor keeps future versions (e.g. claude-opus-5) unaffected. the field. Single strings are returned verbatim when they fit.
"""
if not stop:
return None
stop_max = int(provider_info.get("stop_max", 16))
stop_max_bytes_raw = provider_info.get("stop_max_bytes")
stop_max_bytes = int(stop_max_bytes_raw) if stop_max_bytes_raw is not None else None
def allowed(s: str) -> bool:
if not s:
return False
if stop_max_bytes is not None and len(s.encode("utf-8")) > stop_max_bytes:
logger.warning(
"dropping stop sequence longer than %d bytes",
stop_max_bytes,
)
return False
return True
if isinstance(stop, str):
return stop if allowed(stop) else None
if isinstance(stop, list):
sequences = list(
dict.fromkeys(s for s in stop if isinstance(s, str) and allowed(s))
)
if len(sequences) > stop_max:
logger.warning(
"stop sequences truncated to %d entries (received %d)",
stop_max,
len(sequences),
)
sequences = sequences[:stop_max]
return sequences or None
return None
# Opus 4.7 removed temperature/top_p/top_k (400s on any non-default).
# Only Opus shipped in 4.7; 3.x and 4.5/4.6 still accept all three.
# Trailing -4-7[-.]/EOL anchor keeps future families (claude-opus-5
# etc) unaffected.
# https://platform.claude.com/docs/en/about-claude/models/whats-new-claude-4-7
def _is_openai_family_cloud(base_url: Optional[str]) -> bool: def _is_openai_family_cloud(base_url: Optional[str]) -> bool:
"""True iff ``base_url`` points at OpenAI cloud or Azure OpenAI Foundry. """True iff ``base_url`` points at OpenAI cloud or Azure OpenAI Foundry.
Anchored to the URL host so an attacker can't bypass the gate with a Host-anchored against subdomain-injection (api.openai.com.attacker.com).
path or subdomain like ``https://evil.com/api.openai.com/v1`` or Gates Responses-API extensions (prompt_cache_retention, context_management,
``https://api.openai.com.attacker.com/v1`` (CodeQL py/incomplete-url- container shell) that 400 on non-cloud OAI-compat servers. Azure Foundry
substring-sanitization). Used to scope cloud-only Responses-API matches via .openai.azure.com suffix; leading dot blocks the apex.
extensions (prompt_cache_retention, context_management compaction,
container shell tool) that 400 on non-cloud OpenAI-compatible
servers (ollama / llama.cpp / vLLM).
Azure Foundry resources are scoped to
``<resource-name>.openai.azure.com``; match any subdomain via an
`endswith` on the lowercased hostname, with the leading dot so
`openai.azure.com` itself doesn't slip through (there is no
apex-hosted Azure Foundry endpoint).
""" """
if not base_url: if not base_url:
return False return False
@ -65,9 +97,7 @@ def _is_openai_family_cloud(base_url: Optional[str]) -> bool:
return host == "api.openai.com" or host.endswith(".openai.azure.com") return host == "api.openai.com" or host.endswith(".openai.azure.com")
_ANTHROPIC_4_7_SAMPLING_REMOVED = re.compile( _ANTHROPIC_4_7_SAMPLING_REMOVED = re.compile(r"^claude-opus-4-7(?:[-.]|$)")
r"^claude-(?:opus|sonnet|haiku)-4-7(?:[-.]|$)"
)
_OPENAI_REASONING_SUMMARY_UNSUPPORTED = re.compile(r"^o3(?:[-.]|$)") _OPENAI_REASONING_SUMMARY_UNSUPPORTED = re.compile(r"^o3(?:[-.]|$)")
_OPENAI_REASONING_STATUSES = {"in_progress", "completed", "incomplete"} _OPENAI_REASONING_STATUSES = {"in_progress", "completed", "incomplete"}
@ -866,9 +896,42 @@ class ExternalProviderClient:
anthropic_code_exec_container_id: Optional[str] = None, anthropic_code_exec_container_id: Optional[str] = None,
prompt_cache_ttl: Optional[str] = None, prompt_cache_ttl: Optional[str] = None,
compaction_threshold: Optional[int] = None, compaction_threshold: Optional[int] = None,
frequency_penalty: Optional[float] = None,
seed: Optional[int] = None,
stop: Optional[Union[str, list[str]]] = None,
service_tier: Optional[str] = None,
parallel_tool_calls: Optional[bool] = None,
tools: Optional[list[dict[str, Any]]] = None, tools: Optional[list[dict[str, Any]]] = None,
tool_choice: Optional[Any] = None, tool_choice: Optional[Any] = None,
fast_mode: Optional[bool] = None, fast_mode: Optional[bool] = None,
typical_p: Optional[float] = None,
top_n_sigma: Optional[float] = None,
repeat_last_n: Optional[int] = None,
dynatemp_range: Optional[float] = None,
dynatemp_exponent: Optional[float] = None,
mirostat: Optional[int] = None,
mirostat_tau: Optional[float] = None,
mirostat_eta: Optional[float] = None,
top_a: Optional[float] = None,
dry_multiplier: Optional[float] = None,
dry_base: Optional[float] = None,
dry_allowed_length: Optional[int] = None,
dry_penalty_last_n: Optional[int] = None,
xtc_probability: Optional[float] = None,
xtc_threshold: Optional[float] = None,
min_keep: Optional[int] = None,
ignore_eos: Optional[bool] = None,
min_tokens: Optional[int] = None,
skip_special_tokens: Optional[bool] = None,
spaces_between_special_tokens: Optional[bool] = None,
include_stop_str_in_output: Optional[bool] = None,
truncate_prompt_tokens: Optional[int] = None,
n_keep: Optional[int] = None,
n_probs: Optional[int] = None,
cache_prompt: Optional[bool] = None,
return_tokens: Optional[bool] = None,
timings_per_token: Optional[bool] = None,
post_sampling_probs: Optional[bool] = None,
stream: bool = True, stream: bool = True,
) -> AsyncGenerator[str, None]: ) -> AsyncGenerator[str, None]:
""" """
@ -877,13 +940,14 @@ class ExternalProviderClient:
For OpenAI-compatible providers, lines are forwarded verbatim. For OpenAI-compatible providers, lines are forwarded verbatim.
For Anthropic, the native Messages API SSE is translated to OpenAI format. For Anthropic, the native Messages API SSE is translated to OpenAI format.
``top_k`` and ``presence_penalty`` are forwarded only when the caller Optional sampling extras (``top_k``, ``presence_penalty``,
supplies a value the provider accepts the frontend's ``frequency_penalty``, ``seed``, ``stop``, ``service_tier``,
provider-capability map already filters these per provider, so we ``parallel_tool_calls``) are opt-in. Per-provider helpers silently
treat them as opt-in here. drop fields the upstream rejects (Responses: seed/freq/stop;
Anthropic: seed/freq/logprobs).
``fast_mode`` only applies to Anthropic Opus 4.6 / 4.7 (silently ``fast_mode``: Anthropic Opus 4.6/4.7 only; adds the beta header
dropped elsewhere); adds the beta header and ``speed: "fast"``. and ``speed: "fast"``.
""" """
# tool_choice="none" hard-disables hosted/builtin tools across # tool_choice="none" hard-disables hosted/builtin tools across
# every provider so enabled_tools cannot accidentally bill or leak. # every provider so enabled_tools cannot accidentally bill or leak.
@ -911,6 +975,7 @@ class ExternalProviderClient:
reasoning_effort, reasoning_effort,
tools, tools,
tool_choice, tool_choice,
stop = stop,
): ):
yield line yield line
return return
@ -929,6 +994,9 @@ class ExternalProviderClient:
prompt_cache_ttl, prompt_cache_ttl,
compaction_threshold, compaction_threshold,
tool_choice, tool_choice,
stop = stop,
service_tier = service_tier,
parallel_tool_calls = parallel_tool_calls,
fast_mode = fast_mode, fast_mode = fast_mode,
): ):
yield line yield line
@ -954,6 +1022,8 @@ class ExternalProviderClient:
compaction_threshold, compaction_threshold,
tools, tools,
tool_choice, tool_choice,
service_tier = service_tier,
parallel_tool_calls = parallel_tool_calls,
): ):
yield line yield line
return return
@ -978,6 +1048,11 @@ class ExternalProviderClient:
messages, messages,
model, model,
max_tokens, max_tokens,
frequency_penalty = frequency_penalty,
seed = seed,
stop = stop,
parallel_tool_calls = parallel_tool_calls,
presence_penalty = presence_penalty,
): ):
yield line yield line
return return
@ -997,14 +1072,99 @@ class ExternalProviderClient:
else: else:
body["max_tokens"] = max_tokens body["max_tokens"] = max_tokens
# Drop fields the registry flags as unusable so reasoning-class # Optional sampling extras; `seed_field` renames seed (Mistral),
# models with fixed defaults (Kimi k2.6 etc) don't 400 on pydantic # `body_omit` strips upstream-rejected fields.
# default values that the route layer still fills in.
from core.inference.providers import get_provider_info from core.inference.providers import get_provider_info
provider_info = get_provider_info(self.provider_type) or {} provider_info = get_provider_info(self.provider_type) or {}
if frequency_penalty is not None:
body["frequency_penalty"] = frequency_penalty
if seed is not None:
# Mistral renames `seed` to `random_seed` on /v1/chat/completions.
seed_field = provider_info.get("seed_field", "seed")
body[seed_field] = seed
normalized_stop = _normalize_stop_for_provider(stop, provider_info)
if normalized_stop:
body["stop"] = normalized_stop
# service_tier is OAI-Chat-only here (accepts_service_tier registry
# opt-in); Anthropic/Responses branches set it themselves.
if service_tier is not None and provider_info.get(
"accepts_service_tier", False
):
body["service_tier"] = service_tier
if parallel_tool_calls is not None:
body["parallel_tool_calls"] = parallel_tool_calls
# Extended OAI-compat samplers (OpenRouter `top_a`, vLLM output
# knobs, llama.cpp samplers on custom proxies). Each is gated `is
# not None` so explicit 0/False reach the wire; `body_omit` below
# strips fields the upstream rejects.
if typical_p is not None:
body["typical_p"] = typical_p
if top_n_sigma is not None:
body["top_n_sigma"] = top_n_sigma
if repeat_last_n is not None:
body["repeat_last_n"] = repeat_last_n
if dynatemp_range is not None:
body["dynatemp_range"] = dynatemp_range
if dynatemp_exponent is not None:
body["dynatemp_exponent"] = dynatemp_exponent
if mirostat is not None:
body["mirostat"] = mirostat
if mirostat_tau is not None:
body["mirostat_tau"] = mirostat_tau
if mirostat_eta is not None:
body["mirostat_eta"] = mirostat_eta
if top_a is not None:
body["top_a"] = top_a
if dry_multiplier is not None:
body["dry_multiplier"] = dry_multiplier
if dry_base is not None:
body["dry_base"] = dry_base
if dry_allowed_length is not None:
body["dry_allowed_length"] = dry_allowed_length
if dry_penalty_last_n is not None:
body["dry_penalty_last_n"] = dry_penalty_last_n
if xtc_probability is not None:
body["xtc_probability"] = xtc_probability
if xtc_threshold is not None:
body["xtc_threshold"] = xtc_threshold
if min_keep is not None:
body["min_keep"] = min_keep
if ignore_eos is not None:
body["ignore_eos"] = ignore_eos
if min_tokens is not None:
body["min_tokens"] = min_tokens
if skip_special_tokens is not None:
body["skip_special_tokens"] = skip_special_tokens
if spaces_between_special_tokens is not None:
body["spaces_between_special_tokens"] = spaces_between_special_tokens
if include_stop_str_in_output is not None:
body["include_stop_str_in_output"] = include_stop_str_in_output
if truncate_prompt_tokens is not None:
body["truncate_prompt_tokens"] = truncate_prompt_tokens
if n_keep is not None:
body["n_keep"] = n_keep
if n_probs is not None:
body["n_probs"] = n_probs
if cache_prompt is not None:
body["cache_prompt"] = cache_prompt
if return_tokens is not None:
body["return_tokens"] = return_tokens
if timings_per_token is not None:
body["timings_per_token"] = timings_per_token
if post_sampling_probs is not None:
body["post_sampling_probs"] = post_sampling_probs
# Drop body fields the provider's registry entry locks down
# (e.g. Kimi k2.5/k2.6 only accept temperature=1, top_p=1).
# Also pop the renamed seed field so `body_omit=("seed",)` on a
# provider with `seed_field` rename still strips correctly.
_seed_field = provider_info.get("seed_field", "seed")
for field in provider_info.get("body_omit", ()): for field in provider_info.get("body_omit", ()):
body.pop(field, None) body.pop(field, None)
if field == "seed" and _seed_field != "seed":
body.pop(_seed_field, None)
# Kimi thinking is a top-level body field. kimi-k2-thinking is # Kimi thinking is a top-level body field. kimi-k2-thinking is
# always on (ignore the toggle); kimi-k2.6 defaults on, can be # always on (ignore the toggle); kimi-k2.6 defaults on, can be
@ -1345,6 +1505,12 @@ class ExternalProviderClient:
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
model: str, model: str,
max_tokens: Optional[int], max_tokens: Optional[int],
*,
frequency_penalty: Optional[float] = None,
seed: Optional[int] = None,
stop: Optional[Union[str, list[str]]] = None,
parallel_tool_calls: Optional[bool] = None,
presence_penalty: Optional[float] = None,
) -> AsyncGenerator[str, None]: ) -> AsyncGenerator[str, None]:
""" """
Kimi $web_search round-trip. Kimi $web_search round-trip.
@ -1384,11 +1550,25 @@ class ExternalProviderClient:
if max_tokens is not None: if max_tokens is not None:
body["max_tokens"] = max_tokens body["max_tokens"] = max_tokens
# Strip body fields the Kimi registry declares unusable # Kimi-with-search returns early before the default OAI-compat
# (temperature/top_p — see body_omit in providers.py). # body build; re-apply provider-aware sampling/stop here.
from core.inference.providers import get_provider_info from core.inference.providers import get_provider_info
provider_info = get_provider_info(self.provider_type) or {} provider_info = get_provider_info(self.provider_type) or {}
if presence_penalty is not None:
body["presence_penalty"] = presence_penalty
if frequency_penalty is not None:
body["frequency_penalty"] = frequency_penalty
if seed is not None:
seed_field = provider_info.get("seed_field", "seed")
body[seed_field] = seed
normalized_stop = _normalize_stop_for_provider(stop, provider_info)
if normalized_stop:
body["stop"] = normalized_stop
if parallel_tool_calls is not None:
body["parallel_tool_calls"] = parallel_tool_calls
# Drop body fields the provider's registry entry locks down.
for field in provider_info.get("body_omit", ()): for field in provider_info.get("body_omit", ()):
body.pop(field, None) body.pop(field, None)
@ -1736,6 +1916,9 @@ class ExternalProviderClient:
compaction_threshold: Optional[int] = None, compaction_threshold: Optional[int] = None,
tool_choice: Optional[Any] = None, tool_choice: Optional[Any] = None,
*, *,
stop: Optional[Union[str, list[str]]] = None,
service_tier: Optional[str] = None,
parallel_tool_calls: Optional[bool] = None,
fast_mode: Optional[bool] = None, fast_mode: Optional[bool] = None,
) -> AsyncGenerator[str, None]: ) -> AsyncGenerator[str, None]:
""" """
@ -2037,6 +2220,32 @@ class ExternalProviderClient:
body["temperature"] = temperature body["temperature"] = temperature
if top_k is not None and top_k > 0 and not sampling_removed: if top_k is not None and top_k > 0 and not sampling_removed:
body["top_k"] = top_k body["top_k"] = top_k
# Anthropic body-knob mapping: stop -> stop_sequences (ws-stripped,
# dedup), service_tier (auto|standard_only). parallel_tool_calls is
# handled after tools wiring (Anthropic nests under tool_choice).
if stop:
sequences: list[str]
if isinstance(stop, str):
sequences = [stop] if stop.strip() else []
else:
# Anthropic rejects whitespace-only stop_sequences ("must
# contain non-whitespace"); 16-cap is a client-side guard
# (undocumented max; every SDK uses 16, Bedrock at 8191).
sequences = list(
dict.fromkeys(s for s in stop if isinstance(s, str) and s.strip())
)
if len(sequences) > 16:
logger.warning(
"stop_sequences truncated to 16 entries "
"(received %d, client-side guard ceiling)",
len(sequences),
)
sequences = sequences[:16]
if sequences:
body["stop_sequences"] = sequences
if service_tier in ("auto", "standard_only"):
body["service_tier"] = service_tier
# Anthropic only caches a prefix when at least one cache_control # Anthropic only caches a prefix when at least one cache_control
# marker is attached to it — the frontend defaults # marker is attached to it — the frontend defaults
# enable_prompt_caching to True for Anthropic, so treat `None` the # enable_prompt_caching to True for Anthropic, so treat `None` the
@ -2232,6 +2441,16 @@ class ExternalProviderClient:
if anthropic_code_exec_container_id: if anthropic_code_exec_container_id:
body["container"] = anthropic_code_exec_container_id body["container"] = anthropic_code_exec_container_id
# parallel_tool_calls=False -> tool_choice.disable_parallel_tool_use=True
# (top-level 400s; no-op without tools).
# https://platform.claude.com/docs/en/agents-and-tools/tool-use/implement-tool-use
if parallel_tool_calls is False and body.get("tools"):
tc = body.get("tool_choice")
if not isinstance(tc, dict):
tc = {"type": "auto"}
tc["disable_parallel_tool_use"] = True
body["tool_choice"] = tc
# Server-side compaction (beta `compact-2026-01-12`). Clamps # Server-side compaction (beta `compact-2026-01-12`). Clamps
# below-min thresholds to 50K so the request doesn't 400. # below-min thresholds to 50K so the request doesn't 400.
# https://platform.claude.com/docs/en/build-with-claude/compaction # https://platform.claude.com/docs/en/build-with-claude/compaction
@ -3235,6 +3454,8 @@ class ExternalProviderClient:
reasoning_effort: Optional[str] = None, reasoning_effort: Optional[str] = None,
tools: Optional[list[dict[str, Any]]] = None, tools: Optional[list[dict[str, Any]]] = None,
tool_choice: Optional[Any] = None, tool_choice: Optional[Any] = None,
*,
stop: Optional[Union[str, list[str]]] = None,
) -> AsyncGenerator[str, None]: ) -> AsyncGenerator[str, None]:
""" """
Call Google's native Gemini API and translate its streaming Call Google's native Gemini API and translate its streaming
@ -3928,6 +4149,14 @@ class ExternalProviderClient:
"thinkingBudget": thinking_budget, "thinkingBudget": thinking_budget,
} }
# Gemini's generationConfig.stopSequences (max 5 per native docs).
# https://ai.google.dev/api/generate-content#generationconfig
if stop is not None:
seqs = [stop] if isinstance(stop, str) else list(stop)
seqs = [s for s in seqs if isinstance(s, str) and s][:5]
if seqs:
gen_config["stopSequences"] = seqs
if gen_config: if gen_config:
body["generationConfig"] = gen_config body["generationConfig"] = gen_config
@ -4951,6 +5180,9 @@ class ExternalProviderClient:
compaction_threshold: Optional[int] = None, compaction_threshold: Optional[int] = None,
tools: Optional[list[dict[str, Any]]] = None, tools: Optional[list[dict[str, Any]]] = None,
tool_choice: Optional[Any] = None, tool_choice: Optional[Any] = None,
*,
service_tier: Optional[str] = None,
parallel_tool_calls: Optional[bool] = None,
) -> AsyncGenerator[str, None]: ) -> AsyncGenerator[str, None]:
""" """
Call OpenAI's /v1/responses endpoint and translate its SSE stream back Call OpenAI's /v1/responses endpoint and translate its SSE stream back
@ -5272,6 +5504,12 @@ class ExternalProviderClient:
"input": input_items, "input": input_items,
"stream": True, "stream": True,
} }
# Responses: auto|default|flex|priority (SDK type lists "scale"
# but server 400s; Scale Tier stays on Chat Completions).
if service_tier in ("auto", "default", "flex", "priority"):
body["service_tier"] = service_tier
if parallel_tool_calls is not None:
body["parallel_tool_calls"] = bool(parallel_tool_calls)
if previous_response_id: if previous_response_id:
body["previous_response_id"] = previous_response_id body["previous_response_id"] = previous_response_id
# `summary: "auto"` is what makes /v1/responses emit reasoning # `summary: "auto"` is what makes /v1/responses emit reasoning

View file

@ -4247,6 +4247,36 @@ class LlamaCppBackend:
enable_thinking: Optional[bool] = None, enable_thinking: Optional[bool] = None,
reasoning_effort: Optional[str] = None, reasoning_effort: Optional[str] = None,
preserve_thinking: Optional[bool] = None, preserve_thinking: Optional[bool] = None,
frequency_penalty: Optional[float] = None,
seed: Optional[int] = None,
parallel_tool_calls: Optional[bool] = None,
typical_p: Optional[float] = None,
top_n_sigma: Optional[float] = None,
repeat_last_n: Optional[int] = None,
dynatemp_range: Optional[float] = None,
dynatemp_exponent: Optional[float] = None,
mirostat: Optional[int] = None,
mirostat_tau: Optional[float] = None,
mirostat_eta: Optional[float] = None,
dry_multiplier: Optional[float] = None,
dry_base: Optional[float] = None,
dry_allowed_length: Optional[int] = None,
dry_penalty_last_n: Optional[int] = None,
xtc_probability: Optional[float] = None,
xtc_threshold: Optional[float] = None,
min_keep: Optional[int] = None,
ignore_eos: Optional[bool] = None,
min_tokens: Optional[int] = None,
skip_special_tokens: Optional[bool] = None,
spaces_between_special_tokens: Optional[bool] = None,
include_stop_str_in_output: Optional[bool] = None,
truncate_prompt_tokens: Optional[int] = None,
n_keep: Optional[int] = None,
n_probs: Optional[int] = None,
cache_prompt: Optional[bool] = None,
return_tokens: Optional[bool] = None,
timings_per_token: Optional[bool] = None,
post_sampling_probs: Optional[bool] = None,
) -> Generator[str | dict, None, None]: ) -> Generator[str | dict, None, None]:
""" """
Send a chat completion request to llama-server and stream tokens back. Send a chat completion request to llama-server and stream tokens back.
@ -4286,8 +4316,77 @@ class LlamaCppBackend:
else (self._effective_context_length or _DEFAULT_MAX_TOKENS_FLOOR) else (self._effective_context_length or _DEFAULT_MAX_TOKENS_FLOOR)
) )
payload["t_max_predict_ms"] = _DEFAULT_T_MAX_PREDICT_MS payload["t_max_predict_ms"] = _DEFAULT_T_MAX_PREDICT_MS
# Strip empty/non-string stop entries (mirrors
# `_normalize_stop_for_provider`); stale `stop=["", "END"]` 400s.
if stop: if stop:
payload["stop"] = stop if isinstance(stop, str):
payload["stop"] = stop
elif isinstance(stop, list):
_cleaned = [s for s in stop if isinstance(s, str) and s]
if _cleaned:
payload["stop"] = _cleaned
# `is not None` gate so explicit 0/False reach the wire;
# llama-server ignores unknown fields.
if frequency_penalty is not None:
payload["frequency_penalty"] = frequency_penalty
if seed is not None:
payload["seed"] = seed
if parallel_tool_calls is not None:
payload["parallel_tool_calls"] = parallel_tool_calls
if typical_p is not None:
payload["typical_p"] = typical_p
if top_n_sigma is not None:
payload["top_n_sigma"] = top_n_sigma
if repeat_last_n is not None:
payload["repeat_last_n"] = repeat_last_n
if dynatemp_range is not None:
payload["dynatemp_range"] = dynatemp_range
if dynatemp_exponent is not None:
payload["dynatemp_exponent"] = dynatemp_exponent
if mirostat is not None:
payload["mirostat"] = mirostat
if mirostat_tau is not None:
payload["mirostat_tau"] = mirostat_tau
if mirostat_eta is not None:
payload["mirostat_eta"] = mirostat_eta
if dry_multiplier is not None:
payload["dry_multiplier"] = dry_multiplier
if dry_base is not None:
payload["dry_base"] = dry_base
if dry_allowed_length is not None:
payload["dry_allowed_length"] = dry_allowed_length
if dry_penalty_last_n is not None:
payload["dry_penalty_last_n"] = dry_penalty_last_n
if xtc_probability is not None:
payload["xtc_probability"] = xtc_probability
if xtc_threshold is not None:
payload["xtc_threshold"] = xtc_threshold
if min_keep is not None:
payload["min_keep"] = min_keep
if ignore_eos is not None:
payload["ignore_eos"] = ignore_eos
if min_tokens is not None:
payload["min_tokens"] = min_tokens
if skip_special_tokens is not None:
payload["skip_special_tokens"] = skip_special_tokens
if spaces_between_special_tokens is not None:
payload["spaces_between_special_tokens"] = spaces_between_special_tokens
if include_stop_str_in_output is not None:
payload["include_stop_str_in_output"] = include_stop_str_in_output
if truncate_prompt_tokens is not None:
payload["truncate_prompt_tokens"] = truncate_prompt_tokens
if n_keep is not None:
payload["n_keep"] = n_keep
if n_probs is not None:
payload["n_probs"] = n_probs
if cache_prompt is not None:
payload["cache_prompt"] = cache_prompt
if return_tokens is not None:
payload["return_tokens"] = return_tokens
if timings_per_token is not None:
payload["timings_per_token"] = timings_per_token
if post_sampling_probs is not None:
payload["post_sampling_probs"] = post_sampling_probs
payload["stream_options"] = {"include_usage": True} payload["stream_options"] = {"include_usage": True}
url = f"{self.base_url}/v1/chat/completions" url = f"{self.base_url}/v1/chat/completions"
@ -4430,6 +4529,36 @@ class LlamaCppBackend:
auto_heal_tool_calls: bool = True, auto_heal_tool_calls: bool = True,
tool_call_timeout: int = 300, tool_call_timeout: int = 300,
session_id: Optional[str] = None, session_id: Optional[str] = None,
frequency_penalty: Optional[float] = None,
seed: Optional[int] = None,
parallel_tool_calls: Optional[bool] = None,
typical_p: Optional[float] = None,
top_n_sigma: Optional[float] = None,
repeat_last_n: Optional[int] = None,
dynatemp_range: Optional[float] = None,
dynatemp_exponent: Optional[float] = None,
mirostat: Optional[int] = None,
mirostat_tau: Optional[float] = None,
mirostat_eta: Optional[float] = None,
dry_multiplier: Optional[float] = None,
dry_base: Optional[float] = None,
dry_allowed_length: Optional[int] = None,
dry_penalty_last_n: Optional[int] = None,
xtc_probability: Optional[float] = None,
xtc_threshold: Optional[float] = None,
min_keep: Optional[int] = None,
ignore_eos: Optional[bool] = None,
min_tokens: Optional[int] = None,
skip_special_tokens: Optional[bool] = None,
spaces_between_special_tokens: Optional[bool] = None,
include_stop_str_in_output: Optional[bool] = None,
truncate_prompt_tokens: Optional[int] = None,
n_keep: Optional[int] = None,
n_probs: Optional[int] = None,
cache_prompt: Optional[bool] = None,
return_tokens: Optional[bool] = None,
timings_per_token: Optional[bool] = None,
post_sampling_probs: Optional[bool] = None,
) -> Generator[dict, None, None]: ) -> Generator[dict, None, None]:
""" """
Agentic loop: let the model call tools, execute them, and continue. Agentic loop: let the model call tools, execute them, and continue.
@ -4512,8 +4641,75 @@ class LlamaCppBackend:
else (self._effective_context_length or _DEFAULT_MAX_TOKENS_FLOOR) else (self._effective_context_length or _DEFAULT_MAX_TOKENS_FLOOR)
) )
payload["t_max_predict_ms"] = _DEFAULT_T_MAX_PREDICT_MS payload["t_max_predict_ms"] = _DEFAULT_T_MAX_PREDICT_MS
# Same empty-string filter as the standard payload builder.
if stop: if stop:
payload["stop"] = stop if isinstance(stop, str):
payload["stop"] = stop
elif isinstance(stop, list):
_cleaned = [s for s in stop if isinstance(s, str) and s]
if _cleaned:
payload["stop"] = _cleaned
# Optional sampling extensions; gated on `is not None`.
if frequency_penalty is not None:
payload["frequency_penalty"] = frequency_penalty
if seed is not None:
payload["seed"] = seed
if parallel_tool_calls is not None:
payload["parallel_tool_calls"] = parallel_tool_calls
if typical_p is not None:
payload["typical_p"] = typical_p
if top_n_sigma is not None:
payload["top_n_sigma"] = top_n_sigma
if repeat_last_n is not None:
payload["repeat_last_n"] = repeat_last_n
if dynatemp_range is not None:
payload["dynatemp_range"] = dynatemp_range
if dynatemp_exponent is not None:
payload["dynatemp_exponent"] = dynatemp_exponent
if mirostat is not None:
payload["mirostat"] = mirostat
if mirostat_tau is not None:
payload["mirostat_tau"] = mirostat_tau
if mirostat_eta is not None:
payload["mirostat_eta"] = mirostat_eta
if dry_multiplier is not None:
payload["dry_multiplier"] = dry_multiplier
if dry_base is not None:
payload["dry_base"] = dry_base
if dry_allowed_length is not None:
payload["dry_allowed_length"] = dry_allowed_length
if dry_penalty_last_n is not None:
payload["dry_penalty_last_n"] = dry_penalty_last_n
if xtc_probability is not None:
payload["xtc_probability"] = xtc_probability
if xtc_threshold is not None:
payload["xtc_threshold"] = xtc_threshold
if min_keep is not None:
payload["min_keep"] = min_keep
if ignore_eos is not None:
payload["ignore_eos"] = ignore_eos
if min_tokens is not None:
payload["min_tokens"] = min_tokens
if skip_special_tokens is not None:
payload["skip_special_tokens"] = skip_special_tokens
if spaces_between_special_tokens is not None:
payload["spaces_between_special_tokens"] = spaces_between_special_tokens
if include_stop_str_in_output is not None:
payload["include_stop_str_in_output"] = include_stop_str_in_output
if truncate_prompt_tokens is not None:
payload["truncate_prompt_tokens"] = truncate_prompt_tokens
if n_keep is not None:
payload["n_keep"] = n_keep
if n_probs is not None:
payload["n_probs"] = n_probs
if cache_prompt is not None:
payload["cache_prompt"] = cache_prompt
if return_tokens is not None:
payload["return_tokens"] = return_tokens
if timings_per_token is not None:
payload["timings_per_token"] = timings_per_token
if post_sampling_probs is not None:
payload["post_sampling_probs"] = post_sampling_probs
try: try:
_auth_headers = ( _auth_headers = (
@ -4996,6 +5192,12 @@ class LlamaCppBackend:
_accumulated_predicted_ms += _it.get("predicted_ms", 0) _accumulated_predicted_ms += _it.get("predicted_ms", 0)
_accumulated_predicted_n += _it.get("predicted_n", 0) _accumulated_predicted_n += _it.get("predicted_n", 0)
# parallel_tool_calls=False: client-side cap to 1
# (llama.cpp flag isn't enforced by every jinja template,
# ggml-org/llama.cpp#22043).
if parallel_tool_calls is False and tool_calls:
tool_calls = tool_calls[:1]
assistant_msg = {"role": "assistant", "content": content_text} assistant_msg = {"role": "assistant", "content": content_text}
if tool_calls: if tool_calls:
assistant_msg["tool_calls"] = tool_calls assistant_msg["tool_calls"] = tool_calls
@ -5198,8 +5400,78 @@ class LlamaCppBackend:
else (self._effective_context_length or _DEFAULT_MAX_TOKENS_FLOOR) else (self._effective_context_length or _DEFAULT_MAX_TOKENS_FLOOR)
) )
stream_payload["t_max_predict_ms"] = _DEFAULT_T_MAX_PREDICT_MS stream_payload["t_max_predict_ms"] = _DEFAULT_T_MAX_PREDICT_MS
# Same empty-string filter as the standard / tool payload builder.
if stop: if stop:
stream_payload["stop"] = stop if isinstance(stop, str):
stream_payload["stop"] = stop
elif isinstance(stop, list):
_cleaned = [s for s in stop if isinstance(s, str) and s]
if _cleaned:
stream_payload["stop"] = _cleaned
# Match per-iteration tool-loop sampling for the cap-exhausted
# final-answer pass.
if frequency_penalty is not None:
stream_payload["frequency_penalty"] = frequency_penalty
if seed is not None:
stream_payload["seed"] = seed
if parallel_tool_calls is not None:
stream_payload["parallel_tool_calls"] = parallel_tool_calls
if typical_p is not None:
stream_payload["typical_p"] = typical_p
if top_n_sigma is not None:
stream_payload["top_n_sigma"] = top_n_sigma
if repeat_last_n is not None:
stream_payload["repeat_last_n"] = repeat_last_n
if dynatemp_range is not None:
stream_payload["dynatemp_range"] = dynatemp_range
if dynatemp_exponent is not None:
stream_payload["dynatemp_exponent"] = dynatemp_exponent
if mirostat is not None:
stream_payload["mirostat"] = mirostat
if mirostat_tau is not None:
stream_payload["mirostat_tau"] = mirostat_tau
if mirostat_eta is not None:
stream_payload["mirostat_eta"] = mirostat_eta
if dry_multiplier is not None:
stream_payload["dry_multiplier"] = dry_multiplier
if dry_base is not None:
stream_payload["dry_base"] = dry_base
if dry_allowed_length is not None:
stream_payload["dry_allowed_length"] = dry_allowed_length
if dry_penalty_last_n is not None:
stream_payload["dry_penalty_last_n"] = dry_penalty_last_n
if xtc_probability is not None:
stream_payload["xtc_probability"] = xtc_probability
if xtc_threshold is not None:
stream_payload["xtc_threshold"] = xtc_threshold
if min_keep is not None:
stream_payload["min_keep"] = min_keep
if ignore_eos is not None:
stream_payload["ignore_eos"] = ignore_eos
if min_tokens is not None:
stream_payload["min_tokens"] = min_tokens
if skip_special_tokens is not None:
stream_payload["skip_special_tokens"] = skip_special_tokens
if spaces_between_special_tokens is not None:
stream_payload["spaces_between_special_tokens"] = (
spaces_between_special_tokens
)
if include_stop_str_in_output is not None:
stream_payload["include_stop_str_in_output"] = include_stop_str_in_output
if truncate_prompt_tokens is not None:
stream_payload["truncate_prompt_tokens"] = truncate_prompt_tokens
if n_keep is not None:
stream_payload["n_keep"] = n_keep
if n_probs is not None:
stream_payload["n_probs"] = n_probs
if cache_prompt is not None:
stream_payload["cache_prompt"] = cache_prompt
if return_tokens is not None:
stream_payload["return_tokens"] = return_tokens
if timings_per_token is not None:
stream_payload["timings_per_token"] = timings_per_token
if post_sampling_probs is not None:
stream_payload["post_sampling_probs"] = post_sampling_probs
stream_payload["stream_options"] = {"include_usage": True} stream_payload["stream_options"] = {"include_usage": True}
cumulative = "" cumulative = ""

View file

@ -839,6 +839,7 @@ class InferenceOrchestrator:
tool_call_timeout: int = 300, tool_call_timeout: int = 300,
session_id: Optional[str] = None, session_id: Optional[str] = None,
use_adapter: Optional[Union[bool, str]] = None, use_adapter: Optional[Union[bool, str]] = None,
parallel_tool_calls: Optional[bool] = None,
**_unused, **_unused,
): ):
"""Run the safetensors agentic tool loop in this (parent) """Run the safetensors agentic tool loop in this (parent)
@ -895,6 +896,7 @@ class InferenceOrchestrator:
max_tool_iterations = max_tool_iterations, max_tool_iterations = max_tool_iterations,
tool_call_timeout = tool_call_timeout, tool_call_timeout = tool_call_timeout,
session_id = session_id, session_id = session_id,
parallel_tool_calls = parallel_tool_calls,
) )
def generate_with_adapter_control( def generate_with_adapter_control(

View file

@ -137,11 +137,20 @@ PROVIDER_REGISTRY: dict[str, dict[str, Any]] = {
r"gemini-pro-latest|gemini-flash-latest|gemini-flash-lite-latest" r"gemini-pro-latest|gemini-flash-latest|gemini-flash-lite-latest"
r")$" r")$"
), ),
# Gemini OAI-compat inherits OpenAI's 4-stop cap; default 16
# silently truncates upstream.
# https://ai.google.dev/gemini-api/docs/openai
"stop_max": 4,
}, },
"deepseek": { "deepseek": {
"display_name": "DeepSeek", "display_name": "DeepSeek",
"base_url": "https://api.deepseek.com/v1", "base_url": "https://api.deepseek.com/v1",
# deepseek-chat / deepseek-reasoner retire 2026-07-24; list
# v4-pro / v4-flash alongside for cutover.
# https://api-docs.deepseek.com/updates
"default_models": [ "default_models": [
"deepseek-v4-pro",
"deepseek-v4-flash",
"deepseek-chat", "deepseek-chat",
"deepseek-reasoner", "deepseek-reasoner",
], ],
@ -150,7 +159,7 @@ PROVIDER_REGISTRY: dict[str, dict[str, Any]] = {
"supports_tool_calling": True, "supports_tool_calling": True,
"auth_header": "Authorization", "auth_header": "Authorization",
"auth_prefix": "Bearer ", "auth_prefix": "Bearer ",
"notes": "OpenAI-compatible API. deepseek-chat = V3, deepseek-reasoner = R1 thinking mode.", "notes": "OpenAI-compatible API. deepseek-v4-pro / deepseek-v4-flash are the new canonical ids; deepseek-chat / deepseek-reasoner remain as legacy aliases until 2026-07-24.",
}, },
"mistral": { "mistral": {
"display_name": "Mistral AI", "display_name": "Mistral AI",
@ -180,6 +189,12 @@ PROVIDER_REGISTRY: dict[str, dict[str, Any]] = {
r"mistral-(?:large|medium|small|tiny)-latest|" r"mistral-(?:large|medium|small|tiny)-latest|"
r"mistral-vibe-cli-latest)$" r"mistral-vibe-cli-latest)$"
), ),
# Mistral renames OpenAI's `seed` to `random_seed` on
# /v1/chat/completions. https://docs.mistral.ai/api/endpoint/chat
"seed_field": "random_seed",
# Mistral's docs publish no max but third-party shims cap at 4;
# match OpenAI Chat's cap to avoid silent upstream truncation.
"stop_max": 4,
}, },
"kimi": { "kimi": {
"display_name": "Kimi", "display_name": "Kimi",
@ -203,11 +218,21 @@ PROVIDER_REGISTRY: dict[str, dict[str, Any]] = {
"auth_prefix": "Bearer ", "auth_prefix": "Bearer ",
"notes": "Moonshot API key. China: use base URL https://api.moonshot.cn/v1", "notes": "Moonshot API key. China: use base URL https://api.moonshot.cn/v1",
"model_id_allowlist": re.compile(r"^kimi-k2\.[56]$"), "model_id_allowlist": re.compile(r"^kimi-k2\.[56]$"),
# Both k2.6 and k2.5 are reasoning-class. The API rejects custom # k2.5/k2.6 are reasoning-class: API locks temperature=1, top_p=1,
# sampling: "invalid temperature: only 1 is allowed for this model" # frequency_penalty; presence_penalty / seed / parallel_tool_calls
# (and the same shape for top_p). Strip both fields from the # are undocumented in the Kimi chat schema.
# outbound body so the server falls back to its required defaults. "body_omit": (
"body_omit": ("temperature", "top_p"), "temperature",
"top_p",
"frequency_penalty",
"presence_penalty",
"seed",
"parallel_tool_calls",
),
# Kimi accepts at most 5 stop strings (each <= 32 bytes) per
# https://platform.kimi.ai/docs/api/chat
"stop_max": 5,
"stop_max_bytes": 32,
}, },
"qwen": { "qwen": {
"display_name": "Qwen", "display_name": "Qwen",
@ -354,6 +379,9 @@ PROVIDER_REGISTRY: dict[str, dict[str, Any]] = {
}, },
"notes": "Unified gateway to 300+ models across all major providers. HTTP-Referer and X-Title headers sent for attribution.", "notes": "Unified gateway to 300+ models across all major providers. HTTP-Referer and X-Title headers sent for attribution.",
"model_list_mode": "curated", "model_list_mode": "curated",
# OpenRouter normalises to OpenAI's chat schema and inherits
# the 4-entry stop cap.
"stop_max": 4,
}, },
} }

View file

@ -105,6 +105,7 @@ def run_safetensors_tool_loop(
max_tool_iterations: int = 25, max_tool_iterations: int = 25,
tool_call_timeout: int = 300, tool_call_timeout: int = 300,
session_id: Optional[str] = None, session_id: Optional[str] = None,
parallel_tool_calls: Optional[bool] = None,
) -> Generator[dict, None, None]: ) -> Generator[dict, None, None]:
"""Drive an agentic tool loop on top of a cumulative-text generator. """Drive an agentic tool loop on top of a cumulative-text generator.
@ -293,6 +294,12 @@ def run_safetensors_tool_loop(
yield {"type": "status", "text": ""} yield {"type": "status", "text": ""}
return return
# Mirror the GGUF agentic-loop cap: when the caller opted out
# of parallel tool calls, execute at most one per assistant
# turn even if the model parsed more.
if parallel_tool_calls is False and tool_calls:
tool_calls = tool_calls[:1]
assistant_msg: dict = {"role": "assistant", "content": content_text} assistant_msg: dict = {"role": "assistant", "content": content_text}
if tool_calls: if tool_calls:
assistant_msg["tool_calls"] = tool_calls assistant_msg["tool_calls"] = tool_calls

View file

@ -863,13 +863,187 @@ class ChatCompletionRequest(BaseModel):
"to auto-create." "to auto-create."
), ),
) )
frequency_penalty: Optional[float] = Field(
None,
ge = -2.0,
le = 2.0,
description = (
"OpenAI Chat Completions frequency penalty (-2.0 to 2.0). "
"Forwarded only on providers that accept it; Anthropic "
"Messages and the OpenAI Responses family silently drop it."
),
)
seed: Optional[int] = Field(
None,
description = (
"Best-effort determinism seed. Forwarded to OpenAI Chat and "
"OAI-compat local backends; dropped on Anthropic and OpenAI Responses."
),
)
service_tier: Optional[
Literal["auto", "default", "flex", "priority", "scale", "standard_only"]
] = Field(
None,
description = (
"Provider service tier. Anthropic: auto|standard_only. "
"OpenAI Chat: auto|default|flex|priority|scale. "
"OpenAI Responses: auto|default|flex|priority. "
"Unsupported values are dropped per provider rather than 422'd here."
),
)
parallel_tool_calls: Optional[bool] = Field(
None,
description = (
"Allow parallel tool calls. Forwarded as `parallel_tool_calls` "
"on OpenAI; inverted to `disable_parallel_tool_use` on Anthropic. "
"None preserves upstream default (currently true everywhere)."
),
)
typical_p: Optional[float] = Field(
None,
ge = 0.0,
le = 1.0,
description = "llama.cpp `typ_p`. 1.0 disables. Local only.",
)
top_n_sigma: Optional[float] = Field(
None,
description = "llama.cpp `top_n_sigma`. -1 disables. Local only.",
)
repeat_last_n: Optional[int] = Field(
None,
description = "llama.cpp `repeat_last_n`. 0 disables, -1 = ctx-size. Local only.",
)
dynatemp_range: Optional[float] = Field(
None,
ge = 0.0,
description = "llama.cpp `dynatemp_range`. 0 disables. Local only.",
)
dynatemp_exponent: Optional[float] = Field(
None,
ge = 0.0,
description = "llama.cpp `dynatemp_exponent`. Pairs with dynatemp_range. Local only.",
)
mirostat: Optional[int] = Field(
None,
ge = 0,
le = 2,
description = "llama.cpp `mirostat` (0=off, 1=Mirostat, 2=Mirostat 2.0). Local only.",
)
mirostat_tau: Optional[float] = Field(
None,
ge = 0.0,
description = "llama.cpp `mirostat_tau`. Local only.",
)
mirostat_eta: Optional[float] = Field(
None,
ge = 0.0,
description = "llama.cpp `mirostat_eta`. Local only.",
)
top_a: Optional[float] = Field(
None,
ge = 0.0,
le = 1.0,
description = (
"OpenRouter `top_a`. OpenRouter-only. "
"https://openrouter.ai/docs/api/reference/parameters"
),
)
dry_multiplier: Optional[float] = Field(
None,
ge = 0.0,
description = (
"llama.cpp DRY multiplier. 0 disables the dry_base / "
"dry_allowed_length / dry_penalty_last_n chain. Local only."
),
)
dry_base: Optional[float] = Field(
None,
ge = 1.0,
description = "llama.cpp DRY base. Default 1.75. Local only.",
)
dry_allowed_length: Optional[int] = Field(
None,
ge = 0,
description = "llama.cpp DRY allowed-length. Default 2. Local only.",
)
dry_penalty_last_n: Optional[int] = Field(
None,
description = "llama.cpp DRY scan window. 0 disables, -1 = ctx-size. Local only.",
)
xtc_probability: Optional[float] = Field(
None,
ge = 0.0,
le = 1.0,
description = "llama.cpp XTC probability. 0 disables; pairs with xtc_threshold. Local only.",
)
xtc_threshold: Optional[float] = Field(
None,
ge = 0.0,
le = 1.0,
description = "llama.cpp XTC threshold. Default 0.1. Local only.",
)
min_keep: Optional[int] = Field(
None,
ge = 0,
description = "llama.cpp `min_keep` (force min N past every filter). Local only.",
)
ignore_eos: Optional[bool] = Field(
None,
description = "Continue past EOS. llama.cpp + vLLM only.",
)
min_tokens: Optional[int] = Field(
None,
ge = 0,
description = "Min output tokens before stop / EOS. llama.cpp + vLLM only.",
)
skip_special_tokens: Optional[bool] = Field(
None,
description = "vLLM `skip_special_tokens` (default true). vLLM only.",
)
spaces_between_special_tokens: Optional[bool] = Field(
None,
description = "vLLM `spaces_between_special_tokens` (default true). vLLM only.",
)
include_stop_str_in_output: Optional[bool] = Field(
None,
description = "vLLM `include_stop_str_in_output`. Useful for agentic tools. vLLM only.",
)
truncate_prompt_tokens: Optional[int] = Field(
None,
ge = 1,
description = "vLLM `truncate_prompt_tokens` (left-truncate prompt). vLLM only.",
)
n_keep: Optional[int] = Field(
None,
description = "llama.cpp `n_keep`. 0 disables, -1 = keep all. Local only.",
)
n_probs: Optional[int] = Field(
None,
ge = 0,
description = "llama.cpp `n_probs` (top-N token probs). 0 disables. Local only.",
)
cache_prompt: Optional[bool] = Field(
None,
description = "llama.cpp `cache_prompt` (default true upstream). Local only.",
)
return_tokens: Optional[bool] = Field(
None,
description = "llama.cpp `return_tokens` (debug). Local only.",
)
timings_per_token: Optional[bool] = Field(
None,
description = "llama.cpp `timings_per_token` (perf debug). Local only.",
)
post_sampling_probs: Optional[bool] = Field(
None,
description = "llama.cpp `post_sampling_probs` (sampler debug). Local only.",
)
fast_mode: Optional[bool] = Field( fast_mode: Optional[bool] = Field(
None, None,
description = ( description = (
"[x-unsloth] Anthropic fast-mode toggle. On Claude Opus 4.6 / " "[x-unsloth] Anthropic fast-mode on Opus 4.6 / 4.7. Adds the "
"4.7 adds the `fast-mode-2026-02-01` beta header and sends " "fast-mode-2026-02-01 beta header + speed:'fast' for higher "
"`speed: 'fast'` for higher OTPS at premium pricing. Silently " "OTPS at premium pricing. Silently dropped elsewhere. "
"ignored on every other model + provider. See "
"https://platform.claude.com/docs/en/build-with-claude/fast-mode" "https://platform.claude.com/docs/en/build-with-claude/fast-mode"
), ),
) )

View file

@ -99,6 +99,9 @@ class ChatExportResponse(BaseModel):
class ChatInferenceSettings(BaseModel): class ChatInferenceSettings(BaseModel):
# extra="forbid" requires every persisted key to be listed. Keep
# aligned with PERSISTED_INFERENCE_PARAM_KEYS in
# studio/frontend/src/features/chat/stores/chat-runtime-store.ts.
model_config = ConfigDict(extra = "forbid") model_config = ConfigDict(extra = "forbid")
temperature: Optional[float] = None temperature: Optional[float] = None
@ -107,10 +110,47 @@ class ChatInferenceSettings(BaseModel):
minP: Optional[float] = None minP: Optional[float] = None
repetitionPenalty: Optional[float] = None repetitionPenalty: Optional[float] = None
presencePenalty: Optional[float] = None presencePenalty: Optional[float] = None
frequencyPenalty: Optional[float] = Field(default = None, ge = -2.0, le = 2.0)
seed: Optional[int] = None
stop: Optional[list[str]] = None
serviceTier: Optional[
Literal["auto", "default", "flex", "priority", "scale", "standard_only"]
] = None
parallelToolCalls: Optional[bool] = None
maxSeqLength: Optional[float] = None maxSeqLength: Optional[float] = None
maxTokens: Optional[float] = None maxTokens: Optional[float] = None
systemPrompt: Optional[str] = None systemPrompt: Optional[str] = None
trustRemoteCode: Optional[bool] = None trustRemoteCode: Optional[bool] = None
fastMode: Optional[bool] = None
# Extended llama.cpp / vLLM / OpenRouter samplers exposed by PR #5711.
typicalP: Optional[float] = None
topNSigma: Optional[float] = None
repeatLastN: Optional[int] = None
dynatempRange: Optional[float] = None
dynatempExponent: Optional[float] = None
mirostat: Optional[int] = None
mirostatTau: Optional[float] = None
mirostatEta: Optional[float] = None
topA: Optional[float] = None
dryMultiplier: Optional[float] = None
dryBase: Optional[float] = None
dryAllowedLength: Optional[int] = None
dryPenaltyLastN: Optional[int] = None
xtcProbability: Optional[float] = None
xtcThreshold: Optional[float] = None
minKeep: Optional[int] = None
ignoreEos: Optional[bool] = None
minTokens: Optional[int] = None
skipSpecialTokens: Optional[bool] = None
spacesBetweenSpecialTokens: Optional[bool] = None
includeStopStrInOutput: Optional[bool] = None
truncatePromptTokens: Optional[int] = None
nKeep: Optional[int] = None
nProbs: Optional[int] = None
cachePrompt: Optional[bool] = None
returnTokens: Optional[bool] = None
timingsPerToken: Optional[bool] = None
postSamplingProbs: Optional[bool] = None
class ChatPreset(BaseModel): class ChatPreset(BaseModel):

View file

@ -239,6 +239,19 @@ router = APIRouter()
studio_router = APIRouter() studio_router = APIRouter()
def _clean_local_stop_list(stop) -> Optional[list[str]]:
"""Strip empty/non-string stop entries; returns None when empty so
callers can omit. Mirrors `_normalize_stop_for_provider` so
`stop=["", "END"]` cannot 400 llama-server.
"""
if isinstance(stop, str):
return [stop] if stop else None
if isinstance(stop, list):
cleaned = [s for s in stop if isinstance(s, str) and s]
return cleaned or None
return None
def _detect_safetensors_features(backend, chat_template: Optional[str]) -> dict: def _detect_safetensors_features(backend, chat_template: Optional[str]) -> dict:
"""Classify reasoning/tool capabilities via the GGUF classifier so """Classify reasoning/tool capabilities via the GGUF classifier so
flags match across backends. gpt-oss is overridden because Harmony flags match across backends. gpt-oss is overridden because Harmony
@ -2147,9 +2160,42 @@ async def _proxy_to_external_provider(
anthropic_code_exec_container_id = payload.anthropic_code_exec_container_id, anthropic_code_exec_container_id = payload.anthropic_code_exec_container_id,
prompt_cache_ttl = payload.prompt_cache_ttl, prompt_cache_ttl = payload.prompt_cache_ttl,
compaction_threshold = payload.compaction_threshold, compaction_threshold = payload.compaction_threshold,
frequency_penalty = payload.frequency_penalty,
seed = payload.seed,
stop = payload.stop,
service_tier = payload.service_tier,
parallel_tool_calls = payload.parallel_tool_calls,
tools = payload.tools, tools = payload.tools,
tool_choice = payload.tool_choice, tool_choice = payload.tool_choice,
fast_mode = payload.fast_mode, fast_mode = payload.fast_mode,
typical_p = payload.typical_p,
top_n_sigma = payload.top_n_sigma,
repeat_last_n = payload.repeat_last_n,
dynatemp_range = payload.dynatemp_range,
dynatemp_exponent = payload.dynatemp_exponent,
mirostat = payload.mirostat,
mirostat_tau = payload.mirostat_tau,
mirostat_eta = payload.mirostat_eta,
top_a = payload.top_a,
dry_multiplier = payload.dry_multiplier,
dry_base = payload.dry_base,
dry_allowed_length = payload.dry_allowed_length,
dry_penalty_last_n = payload.dry_penalty_last_n,
xtc_probability = payload.xtc_probability,
xtc_threshold = payload.xtc_threshold,
min_keep = payload.min_keep,
ignore_eos = payload.ignore_eos,
min_tokens = payload.min_tokens,
skip_special_tokens = payload.skip_special_tokens,
spaces_between_special_tokens = payload.spaces_between_special_tokens,
include_stop_str_in_output = payload.include_stop_str_in_output,
truncate_prompt_tokens = payload.truncate_prompt_tokens,
n_keep = payload.n_keep,
n_probs = payload.n_probs,
cache_prompt = payload.cache_prompt,
return_tokens = payload.return_tokens,
timings_per_token = payload.timings_per_token,
post_sampling_probs = payload.post_sampling_probs,
stream = payload.stream, stream = payload.stream,
) )
try: try:
@ -2776,6 +2822,7 @@ async def openai_chat_completions(
max_tokens = payload.max_tokens, max_tokens = payload.max_tokens,
repetition_penalty = payload.repetition_penalty, repetition_penalty = payload.repetition_penalty,
presence_penalty = payload.presence_penalty, presence_penalty = payload.presence_penalty,
stop = _clean_local_stop_list(payload.stop),
cancel_event = cancel_event, cancel_event = cancel_event,
enable_thinking = payload.enable_thinking, enable_thinking = payload.enable_thinking,
reasoning_effort = payload.reasoning_effort, reasoning_effort = payload.reasoning_effort,
@ -2790,6 +2837,36 @@ async def openai_chat_completions(
if payload.tool_call_timeout is not None if payload.tool_call_timeout is not None
else 300, else 300,
session_id = payload.session_id, session_id = payload.session_id,
frequency_penalty = payload.frequency_penalty,
seed = payload.seed,
parallel_tool_calls = payload.parallel_tool_calls,
typical_p = payload.typical_p,
dry_multiplier = payload.dry_multiplier,
dry_base = payload.dry_base,
dry_allowed_length = payload.dry_allowed_length,
dry_penalty_last_n = payload.dry_penalty_last_n,
xtc_probability = payload.xtc_probability,
xtc_threshold = payload.xtc_threshold,
min_keep = payload.min_keep,
ignore_eos = payload.ignore_eos,
min_tokens = payload.min_tokens,
skip_special_tokens = payload.skip_special_tokens,
spaces_between_special_tokens = payload.spaces_between_special_tokens,
include_stop_str_in_output = payload.include_stop_str_in_output,
truncate_prompt_tokens = payload.truncate_prompt_tokens,
n_keep = payload.n_keep,
n_probs = payload.n_probs,
cache_prompt = payload.cache_prompt,
return_tokens = payload.return_tokens,
timings_per_token = payload.timings_per_token,
post_sampling_probs = payload.post_sampling_probs,
top_n_sigma = payload.top_n_sigma,
repeat_last_n = payload.repeat_last_n,
dynatemp_range = payload.dynatemp_range,
dynatemp_exponent = payload.dynatemp_exponent,
mirostat = payload.mirostat,
mirostat_tau = payload.mirostat_tau,
mirostat_eta = payload.mirostat_eta,
) )
_tool_sentinel = object() _tool_sentinel = object()
@ -2955,10 +3032,41 @@ async def openai_chat_completions(
max_tokens = payload.max_tokens, max_tokens = payload.max_tokens,
repetition_penalty = payload.repetition_penalty, repetition_penalty = payload.repetition_penalty,
presence_penalty = payload.presence_penalty, presence_penalty = payload.presence_penalty,
stop = _clean_local_stop_list(payload.stop),
cancel_event = cancel_event, cancel_event = cancel_event,
enable_thinking = payload.enable_thinking, enable_thinking = payload.enable_thinking,
reasoning_effort = payload.reasoning_effort, reasoning_effort = payload.reasoning_effort,
preserve_thinking = payload.preserve_thinking, preserve_thinking = payload.preserve_thinking,
frequency_penalty = payload.frequency_penalty,
seed = payload.seed,
parallel_tool_calls = payload.parallel_tool_calls,
typical_p = payload.typical_p,
dry_multiplier = payload.dry_multiplier,
dry_base = payload.dry_base,
dry_allowed_length = payload.dry_allowed_length,
dry_penalty_last_n = payload.dry_penalty_last_n,
xtc_probability = payload.xtc_probability,
xtc_threshold = payload.xtc_threshold,
min_keep = payload.min_keep,
ignore_eos = payload.ignore_eos,
min_tokens = payload.min_tokens,
skip_special_tokens = payload.skip_special_tokens,
spaces_between_special_tokens = payload.spaces_between_special_tokens,
include_stop_str_in_output = payload.include_stop_str_in_output,
truncate_prompt_tokens = payload.truncate_prompt_tokens,
n_keep = payload.n_keep,
n_probs = payload.n_probs,
cache_prompt = payload.cache_prompt,
return_tokens = payload.return_tokens,
timings_per_token = payload.timings_per_token,
post_sampling_probs = payload.post_sampling_probs,
top_n_sigma = payload.top_n_sigma,
repeat_last_n = payload.repeat_last_n,
dynatemp_range = payload.dynatemp_range,
dynatemp_exponent = payload.dynatemp_exponent,
mirostat = payload.mirostat,
mirostat_tau = payload.mirostat_tau,
mirostat_eta = payload.mirostat_eta,
) )
_gguf_sentinel = object() _gguf_sentinel = object()
@ -3298,6 +3406,7 @@ async def openai_chat_completions(
else 300, else 300,
session_id = payload.session_id, session_id = payload.session_id,
use_adapter = payload.use_adapter, use_adapter = payload.use_adapter,
parallel_tool_calls = payload.parallel_tool_calls,
) )
_sf_tool_sentinel = object() _sf_tool_sentinel = object()
@ -4101,6 +4210,9 @@ def _build_chat_request(
chat_kwargs["top_p"] = payload.top_p chat_kwargs["top_p"] = payload.top_p
if payload.max_output_tokens is not None: if payload.max_output_tokens is not None:
chat_kwargs["max_tokens"] = payload.max_output_tokens chat_kwargs["max_tokens"] = payload.max_output_tokens
# Forward parallel_tool_calls from Responses caller through to llama-server.
if payload.parallel_tool_calls is not None:
chat_kwargs["parallel_tool_calls"] = payload.parallel_tool_calls
chat_tools = _translate_responses_tools_to_chat(payload.tools) chat_tools = _translate_responses_tools_to_chat(payload.tools)
if chat_tools is not None: if chat_tools is not None:
@ -4110,13 +4222,7 @@ def _build_chat_request(
if chat_tool_choice is not None: if chat_tool_choice is not None:
chat_kwargs["tool_choice"] = chat_tool_choice chat_kwargs["tool_choice"] = chat_tool_choice
req = ChatCompletionRequest(**chat_kwargs) return ChatCompletionRequest(**chat_kwargs)
# `parallel_tool_calls` is not a first-class field on ChatCompletionRequest,
# but the model allows extras and _build_openai_passthrough_body forwards
# only explicitly-known fields. Llama-server does not currently implement
# parallel_tool_calls semantics, so we accept-and-ignore it on the
# Responses side to avoid breaking SDK clients that always send it.
return req
def _chat_tool_calls_to_responses_output(tool_calls: list[dict]) -> list[dict]: def _chat_tool_calls_to_responses_output(tool_calls: list[dict]) -> list[dict]:
@ -4167,6 +4273,10 @@ async def _responses_non_streaming(
msg = choices[0].get("message", {}) or {} msg = choices[0].get("message", {}) or {}
text = msg.get("content", "") or "" text = msg.get("content", "") or ""
tool_calls = msg.get("tool_calls") or [] tool_calls = msg.get("tool_calls") or []
# parallel_tool_calls=False -> cap to 1 (llama.cpp flag isn't enforced;
# mirrors GGUF/Anthropic/safetensors paths).
if payload.parallel_tool_calls is False and tool_calls:
tool_calls = tool_calls[:1]
usage_data = body.get("usage", {}) usage_data = body.get("usage", {})
input_tokens = usage_data.get("prompt_tokens", 0) input_tokens = usage_data.get("prompt_tokens", 0)
@ -4288,6 +4398,11 @@ async def _responses_stream(
tool_call_state: dict[int, dict] = {} tool_call_state: dict[int, dict] = {}
# Text message lives at output_index 0; tool calls claim 1, 2, ... # Text message lives at output_index 0; tool calls claim 1, 2, ...
next_output_index = 1 next_output_index = 1
# parallel_tool_calls=False: latch the first tc index, drop the
# rest; llama.cpp flag isn't enforced by every jinja template
# (ggml-org/llama.cpp#22043).
serial_tool_calls = payload.parallel_tool_calls is False
first_serial_idx: Optional[int] = None
def _snapshot_output() -> list[dict]: def _snapshot_output() -> list[dict]:
"""Snapshot of all completed output items for response.completed.""" """Snapshot of all completed output items for response.completed."""
@ -4404,6 +4519,11 @@ async def _responses_stream(
for tc in delta.get("tool_calls") or []: for tc in delta.get("tool_calls") or []:
idx = tc.get("index", 0) idx = tc.get("index", 0)
if serial_tool_calls:
if first_serial_idx is None:
first_serial_idx = idx
if idx != first_serial_idx:
continue
st = tool_call_state.get(idx) st = tool_call_state.get(idx)
fn = tc.get("function") or {} fn = tc.get("function") or {}
if st is None: if st is None:
@ -4773,6 +4893,15 @@ async def anthropic_messages(
if openai_tool_choice is None: if openai_tool_choice is None:
openai_tool_choice = "auto" openai_tool_choice = "auto"
# Anthropic nests `disable_parallel_tool_use` under `tool_choice`;
# flip to OAI `parallel_tool_calls` so the local GGUF tool loop honors it.
# https://docs.claude.com/en/docs/agents-and-tools/tool-use/implement-tool-use
anthropic_parallel_tool_calls: Optional[bool] = None
if isinstance(payload.tool_choice, dict):
_disable = payload.tool_choice.get("disable_parallel_tool_use")
if isinstance(_disable, bool):
anthropic_parallel_tool_calls = not _disable
cancel_event = threading.Event() cancel_event = threading.Event()
# ── Tool routing ────────────────────────────────────────── # ── Tool routing ──────────────────────────────────────────
@ -4869,6 +4998,7 @@ async def anthropic_messages(
repetition_penalty = repetition_penalty, repetition_penalty = repetition_penalty,
presence_penalty = presence_penalty, presence_penalty = presence_penalty,
tool_choice = openai_tool_choice, tool_choice = openai_tool_choice,
parallel_tool_calls = anthropic_parallel_tool_calls,
session_id = payload.session_id, session_id = payload.session_id,
cancel_id = payload.cancel_id, cancel_id = payload.cancel_id,
) )
@ -4887,6 +5017,7 @@ async def anthropic_messages(
repetition_penalty = repetition_penalty, repetition_penalty = repetition_penalty,
presence_penalty = presence_penalty, presence_penalty = presence_penalty,
tool_choice = openai_tool_choice, tool_choice = openai_tool_choice,
parallel_tool_calls = anthropic_parallel_tool_calls,
) )
if server_tools: if server_tools:
@ -4978,6 +5109,7 @@ async def anthropic_messages(
auto_heal_tool_calls = True, auto_heal_tool_calls = True,
tool_call_timeout = 300, tool_call_timeout = 300,
session_id = payload.session_id, session_id = payload.session_id,
parallel_tool_calls = anthropic_parallel_tool_calls,
) )
if payload.stream: if payload.stream:
@ -5229,6 +5361,36 @@ def _build_passthrough_payload(
min_p = None, min_p = None,
repetition_penalty = None, repetition_penalty = None,
presence_penalty = None, presence_penalty = None,
frequency_penalty = None,
seed = None,
parallel_tool_calls = None,
typical_p = None,
top_n_sigma = None,
repeat_last_n = None,
dynatemp_range = None,
dynatemp_exponent = None,
mirostat = None,
mirostat_tau = None,
mirostat_eta = None,
dry_multiplier = None,
dry_base = None,
dry_allowed_length = None,
dry_penalty_last_n = None,
xtc_probability = None,
xtc_threshold = None,
min_keep = None,
ignore_eos = None,
min_tokens = None,
skip_special_tokens = None,
spaces_between_special_tokens = None,
include_stop_str_in_output = None,
truncate_prompt_tokens = None,
n_keep = None,
n_probs = None,
cache_prompt = None,
return_tokens = None,
timings_per_token = None,
post_sampling_probs = None,
tool_choice = "auto", tool_choice = "auto",
response_format = None, response_format = None,
chat_template_kwargs = None, chat_template_kwargs = None,
@ -5251,8 +5413,16 @@ def _build_passthrough_payload(
else (backend_ctx or _DEFAULT_MAX_TOKENS_FLOOR) else (backend_ctx or _DEFAULT_MAX_TOKENS_FLOOR)
) )
body["t_max_predict_ms"] = _DEFAULT_T_MAX_PREDICT_MS body["t_max_predict_ms"] = _DEFAULT_T_MAX_PREDICT_MS
# Strip empty stop entries (mirrors `_normalize_stop_for_provider`);
# stale `stop=["", "END"]` would 400 llama-server.
if stop: if stop:
body["stop"] = stop if isinstance(stop, str):
if stop:
body["stop"] = stop
elif isinstance(stop, list):
cleaned = [s for s in stop if isinstance(s, str) and s]
if cleaned:
body["stop"] = cleaned
if min_p is not None: if min_p is not None:
body["min_p"] = min_p body["min_p"] = min_p
if repetition_penalty is not None: if repetition_penalty is not None:
@ -5260,16 +5430,76 @@ def _build_passthrough_payload(
body["repeat_penalty"] = repetition_penalty body["repeat_penalty"] = repetition_penalty
if presence_penalty is not None: if presence_penalty is not None:
body["presence_penalty"] = presence_penalty body["presence_penalty"] = presence_penalty
# parallel_tool_calls is a no-op on llama-server today but forwarded
# for future support. `is not None` gate lets explicit 0/False through;
# llama-server ignores unknowns, Ollama drops non-OAI fields.
if frequency_penalty is not None:
body["frequency_penalty"] = frequency_penalty
if seed is not None:
body["seed"] = seed
if parallel_tool_calls is not None:
body["parallel_tool_calls"] = parallel_tool_calls
if typical_p is not None:
body["typical_p"] = typical_p
if top_n_sigma is not None:
body["top_n_sigma"] = top_n_sigma
if repeat_last_n is not None:
body["repeat_last_n"] = repeat_last_n
if dynatemp_range is not None:
body["dynatemp_range"] = dynatemp_range
if dynatemp_exponent is not None:
body["dynatemp_exponent"] = dynatemp_exponent
if mirostat is not None:
body["mirostat"] = mirostat
if mirostat_tau is not None:
body["mirostat_tau"] = mirostat_tau
if mirostat_eta is not None:
body["mirostat_eta"] = mirostat_eta
if dry_multiplier is not None:
body["dry_multiplier"] = dry_multiplier
if dry_base is not None:
body["dry_base"] = dry_base
if dry_allowed_length is not None:
body["dry_allowed_length"] = dry_allowed_length
if dry_penalty_last_n is not None:
body["dry_penalty_last_n"] = dry_penalty_last_n
if xtc_probability is not None:
body["xtc_probability"] = xtc_probability
if xtc_threshold is not None:
body["xtc_threshold"] = xtc_threshold
if min_keep is not None:
body["min_keep"] = min_keep
if ignore_eos is not None:
body["ignore_eos"] = ignore_eos
if min_tokens is not None:
body["min_tokens"] = min_tokens
if skip_special_tokens is not None:
body["skip_special_tokens"] = skip_special_tokens
if spaces_between_special_tokens is not None:
body["spaces_between_special_tokens"] = spaces_between_special_tokens
if include_stop_str_in_output is not None:
body["include_stop_str_in_output"] = include_stop_str_in_output
if truncate_prompt_tokens is not None:
body["truncate_prompt_tokens"] = truncate_prompt_tokens
if n_keep is not None:
body["n_keep"] = n_keep
if n_probs is not None:
body["n_probs"] = n_probs
if cache_prompt is not None:
body["cache_prompt"] = cache_prompt
if return_tokens is not None:
body["return_tokens"] = return_tokens
if timings_per_token is not None:
body["timings_per_token"] = timings_per_token
if post_sampling_probs is not None:
body["post_sampling_probs"] = post_sampling_probs
if response_format is not None: if response_format is not None:
# llama-server applies a GBNF grammar derived from the JSON schema # llama-server applies a GBNF grammar from the JSON schema.
# when response_format is present. Field is documented flat at the # Field is documented flat at the request root.
# request root (tools/server/README.md), which is also what the
# OpenAI SDK produces by spreading extra_body into the body top.
body["response_format"] = response_format body["response_format"] = response_format
if chat_template_kwargs is not None: if chat_template_kwargs is not None:
# Propagate reasoning / template overrides (e.g. enable_thinking) # Reasoning / template overrides (e.g. enable_thinking) so
# so llama-server renders the Jinja template in the mode the caller # llama-server renders the Jinja template in the requested mode.
# asked for instead of whatever default the model was loaded with.
body["chat_template_kwargs"] = chat_template_kwargs body["chat_template_kwargs"] = chat_template_kwargs
return body return body
@ -5291,6 +5521,7 @@ async def _anthropic_passthrough_stream(
repetition_penalty = None, repetition_penalty = None,
presence_penalty = None, presence_penalty = None,
tool_choice = "auto", tool_choice = "auto",
parallel_tool_calls = None,
session_id = None, session_id = None,
cancel_id = None, cancel_id = None,
): ):
@ -5309,6 +5540,7 @@ async def _anthropic_passthrough_stream(
min_p = min_p, min_p = min_p,
repetition_penalty = repetition_penalty, repetition_penalty = repetition_penalty,
presence_penalty = presence_penalty, presence_penalty = presence_penalty,
parallel_tool_calls = parallel_tool_calls,
tool_choice = tool_choice, tool_choice = tool_choice,
backend_ctx = llama_backend.context_length, backend_ctx = llama_backend.context_length,
) )
@ -5319,7 +5551,9 @@ async def _anthropic_passthrough_stream(
_tracker.__enter__() _tracker.__enter__()
async def _stream(): async def _stream():
emitter = AnthropicPassthroughEmitter() emitter = AnthropicPassthroughEmitter(
parallel_tool_calls = parallel_tool_calls,
)
for line in emitter.start(message_id, model_name): for line in emitter.start(message_id, model_name):
yield line yield line
@ -5443,6 +5677,7 @@ async def _anthropic_passthrough_non_streaming(
repetition_penalty = None, repetition_penalty = None,
presence_penalty = None, presence_penalty = None,
tool_choice = "auto", tool_choice = "auto",
parallel_tool_calls = None,
): ):
"""Non-streaming client-side pass-through.""" """Non-streaming client-side pass-through."""
target_url = f"{llama_backend.base_url}/v1/chat/completions" target_url = f"{llama_backend.base_url}/v1/chat/completions"
@ -5458,6 +5693,7 @@ async def _anthropic_passthrough_non_streaming(
min_p = min_p, min_p = min_p,
repetition_penalty = repetition_penalty, repetition_penalty = repetition_penalty,
presence_penalty = presence_penalty, presence_penalty = presence_penalty,
parallel_tool_calls = parallel_tool_calls,
tool_choice = tool_choice, tool_choice = tool_choice,
backend_ctx = llama_backend.context_length, backend_ctx = llama_backend.context_length,
) )
@ -5484,6 +5720,10 @@ async def _anthropic_passthrough_non_streaming(
content_blocks.append(AnthropicResponseTextBlock(text = text)) content_blocks.append(AnthropicResponseTextBlock(text = text))
tool_calls = message.get("tool_calls") or [] tool_calls = message.get("tool_calls") or []
# parallel_tool_calls=False: cap to 1 tool_use block; llama.cpp flag
# isn't enforced by every jinja template (ggml-org/llama.cpp#22043).
if parallel_tool_calls is False and tool_calls:
tool_calls = tool_calls[:1]
for tc in tool_calls: for tc in tool_calls:
fn = tc.get("function") or {} fn = tc.get("function") or {}
try: try:
@ -5786,6 +6026,36 @@ def _build_openai_passthrough_body(payload, backend_ctx = None) -> dict:
min_p = payload.min_p, min_p = payload.min_p,
repetition_penalty = payload.repetition_penalty, repetition_penalty = payload.repetition_penalty,
presence_penalty = payload.presence_penalty, presence_penalty = payload.presence_penalty,
frequency_penalty = payload.frequency_penalty,
seed = payload.seed,
parallel_tool_calls = payload.parallel_tool_calls,
typical_p = payload.typical_p,
top_n_sigma = payload.top_n_sigma,
repeat_last_n = payload.repeat_last_n,
dynatemp_range = payload.dynatemp_range,
dynatemp_exponent = payload.dynatemp_exponent,
mirostat = payload.mirostat,
mirostat_tau = payload.mirostat_tau,
mirostat_eta = payload.mirostat_eta,
dry_multiplier = payload.dry_multiplier,
dry_base = payload.dry_base,
dry_allowed_length = payload.dry_allowed_length,
dry_penalty_last_n = payload.dry_penalty_last_n,
xtc_probability = payload.xtc_probability,
xtc_threshold = payload.xtc_threshold,
min_keep = payload.min_keep,
ignore_eos = payload.ignore_eos,
min_tokens = payload.min_tokens,
skip_special_tokens = payload.skip_special_tokens,
spaces_between_special_tokens = payload.spaces_between_special_tokens,
include_stop_str_in_output = payload.include_stop_str_in_output,
truncate_prompt_tokens = payload.truncate_prompt_tokens,
n_keep = payload.n_keep,
n_probs = payload.n_probs,
cache_prompt = payload.cache_prompt,
return_tokens = payload.return_tokens,
timings_per_token = payload.timings_per_token,
post_sampling_probs = payload.post_sampling_probs,
tool_choice = tool_choice, tool_choice = tool_choice,
response_format = _extract_response_format(payload), response_format = _extract_response_format(payload),
chat_template_kwargs = tpl_kwargs, chat_template_kwargs = tpl_kwargs,

View file

@ -63,16 +63,18 @@ def test_cpu_thread_cap_is_opt_in(raw):
# Anything that is not a positive integer raises a clear ValueError. # Anything that is not a positive integer raises a clear ValueError.
@pytest.mark.parametrize("raw", ["zero", "0", "-3", "1.5", "abc", "8a", "0x4", "1e3", "4 0"]) @pytest.mark.parametrize(
"raw", ["zero", "0", "-3", "1.5", "abc", "8a", "0x4", "1e3", "4 0"]
)
def test_cpu_thread_cap_requires_positive_integer(raw): def test_cpu_thread_cap_requires_positive_integer(raw):
with pytest.raises(ValueError, match="must be a positive integer"): with pytest.raises(ValueError, match = "must be a positive integer"):
configure_cpu_threads({"UNSLOTH_CPU_THREADS": raw}) configure_cpu_threads({"UNSLOTH_CPU_THREADS": raw})
# env=None path uses real os.environ (production call from run.py / main.py). # env=None path uses real os.environ (production call from run.py / main.py).
def test_cpu_thread_cap_uses_os_environ_when_env_is_none(monkeypatch): def test_cpu_thread_cap_uses_os_environ_when_env_is_none(monkeypatch):
for variable in (*_THREAD_POOL_ENV_VARS, "UNSLOTH_CPU_THREADS"): for variable in (*_THREAD_POOL_ENV_VARS, "UNSLOTH_CPU_THREADS"):
monkeypatch.delenv(variable, raising=False) monkeypatch.delenv(variable, raising = False)
monkeypatch.setenv("UNSLOTH_CPU_THREADS", "3") monkeypatch.setenv("UNSLOTH_CPU_THREADS", "3")
configure_cpu_threads() configure_cpu_threads()
@ -84,7 +86,7 @@ def test_cpu_thread_cap_uses_os_environ_when_env_is_none(monkeypatch):
# Calling twice must not flip any seeded value. # Calling twice must not flip any seeded value.
def test_cpu_thread_cap_idempotent(monkeypatch): def test_cpu_thread_cap_idempotent(monkeypatch):
for variable in (*_THREAD_POOL_ENV_VARS, "UNSLOTH_CPU_THREADS"): for variable in (*_THREAD_POOL_ENV_VARS, "UNSLOTH_CPU_THREADS"):
monkeypatch.delenv(variable, raising=False) monkeypatch.delenv(variable, raising = False)
monkeypatch.setenv("UNSLOTH_CPU_THREADS", "5") monkeypatch.setenv("UNSLOTH_CPU_THREADS", "5")
configure_cpu_threads() configure_cpu_threads()
@ -138,9 +140,9 @@ def test_invalid_cpu_thread_cap_exits_without_traceback(entry_point):
result = subprocess.run( result = subprocess.run(
[sys.executable, str(entry_point)], [sys.executable, str(entry_point)],
env=env, env = env,
capture_output=True, capture_output = True,
text=True, text = True,
) )
assert result.returncode == 1 assert result.returncode == 1

View file

@ -275,18 +275,19 @@ class TestChatCompletionRequestToolFields:
assert req.stop is None assert req.stop is None
def test_extra_fields_accepted(self): def test_extra_fields_accepted(self):
# `frequency_penalty`, `seed`, `response_format` are not yet # ``response_format`` is still an undeclared OpenAI-side field;
# explicitly declared but must survive Pydantic parsing now that # it must survive Pydantic parsing because extra="allow" is set.
# extra="allow" is set. # ``frequency_penalty`` and ``seed`` were promoted to explicit
# ChatCompletionRequest fields in the sampling-params PR, so
# they now ride the attribute path, not model_extra.
req = self._make( req = self._make(
frequency_penalty = 0.5, frequency_penalty = 0.5,
seed = 42, seed = 42,
response_format = {"type": "json_object"}, response_format = {"type": "json_object"},
) )
# Extras land in model_extra assert req.frequency_penalty == 0.5
assert req.seed == 42
assert req.model_extra is not None assert req.model_extra is not None
assert req.model_extra.get("frequency_penalty") == 0.5
assert req.model_extra.get("seed") == 42
assert req.model_extra.get("response_format") == {"type": "json_object"} assert req.model_extra.get("response_format") == {"type": "json_object"}
def test_unsloth_extensions_still_work(self): def test_unsloth_extensions_still_work(self):

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,112 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { XIcon } from "lucide-react";
import { type KeyboardEvent, useState } from "react";
/** Chips editor for stop/stop_sequences. Enter or comma commits; Backspace
* on empty deletes the last. OpenAI Chat caps at 4; pass Infinity to disable. */
export interface StopSequencesInputProps {
value: string[];
onChange: (next: string[]) => void;
maxEntries?: number;
disabled?: boolean;
placeholder?: string;
className?: string;
"aria-label"?: string;
}
export function StopSequencesInput({
value,
onChange,
maxEntries = 4,
disabled,
placeholder = "Add stop sequence",
className,
"aria-label": ariaLabel,
}: StopSequencesInputProps) {
const [draft, setDraft] = useState("");
const atCap = value.length >= maxEntries;
function commitDraft() {
// Preserve whitespace exactly (stops are byte-exact); llama-server
// accepts "\n\n" for blank-line halts. Anthropic strips whitespace
// entries on the wire.
if (!draft) return;
if (atCap) return;
if (value.includes(draft)) {
setDraft("");
return;
}
onChange([...value, draft]);
setDraft("");
}
function removeChip(index: number) {
if (disabled) return;
onChange(value.filter((_, i) => i !== index));
}
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (disabled) return;
if (event.key === "Enter" || event.key === ",") {
event.preventDefault();
commitDraft();
return;
}
if (event.key === "Backspace" && !draft && value.length > 0) {
event.preventDefault();
onChange(value.slice(0, -1));
}
}
return (
<div
data-slot="stop-sequences-input"
className={cn(
"flex flex-wrap items-center gap-1.5 rounded-md border border-input bg-transparent px-2 py-1.5 text-sm",
"focus-within:border-ring focus-within:ring-[1px] focus-within:ring-ring/40",
disabled && "cursor-not-allowed opacity-60",
className,
)}
aria-label={ariaLabel}
>
{value.map((entry, index) => (
<Badge
// Not unique across edits (re-typed value); combine value+index for a stable key.
key={`${entry}-${index}`}
variant="secondary"
className="gap-1 pl-2 pr-1"
>
<span className="font-mono">{entry}</span>
{!disabled ? (
<button
type="button"
onClick={() => removeChip(index)}
className="ml-0.5 rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={`Remove stop sequence ${entry}`}
>
<XIcon className="size-3" />
</button>
) : null}
</Badge>
))}
<Input
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={handleKeyDown}
onBlur={commitDraft}
placeholder={atCap ? `Max ${maxEntries} stops` : placeholder}
disabled={disabled || atCap}
aria-label={ariaLabel || placeholder}
className={cn(
"h-6 min-w-[8ch] flex-1 border-0 bg-transparent p-0 text-sm shadow-none",
"focus-visible:ring-0 focus-visible:border-0",
)}
/>
</div>
);
}

View file

@ -1806,6 +1806,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
); );
const externalCapabilities = getProviderCapabilities( const externalCapabilities = getProviderCapabilities(
externalProvider?.providerType, externalProvider?.providerType,
externalSelection?.modelId,
); );
const externalReasoningCaps: ReturnType< const externalReasoningCaps: ReturnType<
typeof getExternalReasoningCapabilities typeof getExternalReasoningCapabilities
@ -1990,6 +1991,166 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
...(externalCapabilities?.presencePenalty ...(externalCapabilities?.presencePenalty
? { presence_penalty: params.presencePenalty } ? { presence_penalty: params.presencePenalty }
: {}), : {}),
// Optional sampling extras. Per-provider gates live in
// provider-capabilities.ts; backend drops unknown fields.
...(externalCapabilities?.frequencyPenalty
? { frequency_penalty: params.frequencyPenalty }
: {}),
...(externalCapabilities?.seed && params.seed !== null
? { seed: params.seed }
: {}),
...(externalCapabilities?.stop && params.stop.length > 0
? { stop: params.stop }
: {}),
...(externalCapabilities?.serviceTier && params.serviceTier
? { service_tier: params.serviceTier }
: {}),
// Upstream default is true on every shipped provider;
// forward only on explicit opt-out.
...(externalCapabilities?.parallelToolCalls &&
params.parallelToolCalls === false
? { parallel_tool_calls: false }
: {}),
// llama.cpp / vLLM / OpenRouter extras: cap-flag plus
// non-default value gates so only meaningful knobs hit wire.
...(externalCapabilities?.typicalP &&
params.typicalP !== null &&
params.typicalP !== 1
? { typical_p: params.typicalP }
: {}),
...(externalCapabilities?.topNSigma &&
params.topNSigma !== null &&
params.topNSigma !== -1
? { top_n_sigma: params.topNSigma }
: {}),
...(externalCapabilities?.repeatLastN &&
params.repeatLastN !== null
? { repeat_last_n: params.repeatLastN }
: {}),
// Dynatemp: range>0 unlocks both fields.
...(externalCapabilities?.dynatempRange &&
params.dynatempRange !== null &&
params.dynatempRange > 0
? {
dynatemp_range: params.dynatempRange,
...(externalCapabilities?.dynatempExponent &&
params.dynatempExponent !== null
? { dynatemp_exponent: params.dynatempExponent }
: {}),
}
: {}),
// Mirostat: mode!=0 unlocks tau + eta.
...(externalCapabilities?.mirostat &&
params.mirostat !== null &&
params.mirostat !== 0
? {
mirostat: params.mirostat,
...(externalCapabilities?.mirostatTau &&
params.mirostatTau !== null
? { mirostat_tau: params.mirostatTau }
: {}),
...(externalCapabilities?.mirostatEta &&
params.mirostatEta !== null
? { mirostat_eta: params.mirostatEta }
: {}),
}
: {}),
...(externalCapabilities?.topA &&
params.topA !== null &&
params.topA > 0
? { top_a: params.topA }
: {}),
// DRY: multiplier>0 unlocks the 4-field chain.
...(externalCapabilities?.dryMultiplier &&
params.dryMultiplier !== null &&
params.dryMultiplier > 0
? {
dry_multiplier: params.dryMultiplier,
...(externalCapabilities?.dryBase &&
params.dryBase !== null
? { dry_base: params.dryBase }
: {}),
...(externalCapabilities?.dryAllowedLength &&
params.dryAllowedLength !== null
? { dry_allowed_length: params.dryAllowedLength }
: {}),
...(externalCapabilities?.dryPenaltyLastN &&
params.dryPenaltyLastN !== null
? { dry_penalty_last_n: params.dryPenaltyLastN }
: {}),
}
: {}),
// XTC: probability>0 unlocks threshold.
...(externalCapabilities?.xtcProbability &&
params.xtcProbability !== null &&
params.xtcProbability > 0
? {
xtc_probability: params.xtcProbability,
...(externalCapabilities?.xtcThreshold &&
params.xtcThreshold !== null
? { xtc_threshold: params.xtcThreshold }
: {}),
}
: {}),
...(externalCapabilities?.minKeep &&
params.minKeep !== null &&
params.minKeep > 0
? { min_keep: params.minKeep }
: {}),
...(externalCapabilities?.ignoreEos && params.ignoreEos === true
? { ignore_eos: true }
: {}),
...(externalCapabilities?.minTokens &&
params.minTokens !== null &&
params.minTokens > 0
? { min_tokens: params.minTokens }
: {}),
// vLLM output-shape: default true for skip/spaces, false
// for include-stop. Forward only on user opt-out.
...(externalCapabilities?.skipSpecialTokens &&
params.skipSpecialTokens === false
? { skip_special_tokens: false }
: {}),
...(externalCapabilities?.spacesBetweenSpecialTokens &&
params.spacesBetweenSpecialTokens === false
? { spaces_between_special_tokens: false }
: {}),
...(externalCapabilities?.includeStopStrInOutput &&
params.includeStopStrInOutput === true
? { include_stop_str_in_output: true }
: {}),
...(externalCapabilities?.truncatePromptTokens &&
params.truncatePromptTokens !== null &&
params.truncatePromptTokens > 0
? { truncate_prompt_tokens: params.truncatePromptTokens }
: {}),
// n_keep accepts -1 (keep all), so the gate is != 0.
...(externalCapabilities?.nKeep &&
params.nKeep !== null &&
params.nKeep !== 0
? { n_keep: params.nKeep }
: {}),
...(externalCapabilities?.nProbs &&
params.nProbs !== null &&
params.nProbs > 0
? { n_probs: params.nProbs }
: {}),
...(externalCapabilities?.cachePrompt &&
params.cachePrompt === false
? { cache_prompt: false }
: {}),
...(externalCapabilities?.returnTokens &&
params.returnTokens === true
? { return_tokens: true }
: {}),
...(externalCapabilities?.timingsPerToken &&
params.timingsPerToken === true
? { timings_per_token: true }
: {}),
...(externalCapabilities?.postSamplingProbs &&
params.postSamplingProbs === true
? { post_sampling_probs: true }
: {}),
// Compose the enabled_tools list from the active pills; // Compose the enabled_tools list from the active pills;
// backend maps each name to the provider's tool schema. // backend maps each name to the provider's tool schema.
...(webSearchEnabledForThisTurn || ...(webSearchEnabledForThisTurn ||
@ -2045,9 +2206,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
isPromptCacheTtl(externalProvider.promptCacheTtl) isPromptCacheTtl(externalProvider.promptCacheTtl)
? { prompt_cache_ttl: externalProvider.promptCacheTtl } ? { prompt_cache_ttl: externalProvider.promptCacheTtl }
: {}), : {}),
// Anthropic fast mode (Opus 4.6 / 4.7 only); backend // Fast mode (Anthropic Opus 4.6 / 4.7). Backend drops on
// silently drops on unsupported models as a second // unsupported models as second defence.
// line of defence.
...(params.fastMode && ...(params.fastMode &&
providerSupportsFastMode( providerSupportsFastMode(
externalProvider.providerType, externalProvider.providerType,
@ -2080,6 +2240,107 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
min_p: params.minP, min_p: params.minP,
repetition_penalty: params.repetitionPenalty, repetition_penalty: params.repetitionPenalty,
presence_penalty: params.presencePenalty, presence_penalty: params.presencePenalty,
// llama-server (_build_passthrough_payload) accepts OAI
// extras and ignores unknown; parallel_tool_calls default
// false upstream so forward unconditionally.
...(params.frequencyPenalty !== 0
? { frequency_penalty: params.frequencyPenalty }
: {}),
...(params.seed !== null ? { seed: params.seed } : {}),
...(params.stop.length > 0 ? { stop: params.stop } : {}),
...(params.typicalP !== null && params.typicalP !== 1
? { typical_p: params.typicalP }
: {}),
...(params.topNSigma !== null && params.topNSigma !== -1
? { top_n_sigma: params.topNSigma }
: {}),
...(params.repeatLastN !== null
? { repeat_last_n: params.repeatLastN }
: {}),
...(params.dynatempRange !== null && params.dynatempRange > 0
? {
dynatemp_range: params.dynatempRange,
...(params.dynatempExponent !== null
? { dynatemp_exponent: params.dynatempExponent }
: {}),
}
: {}),
...(params.mirostat !== null && params.mirostat !== 0
? {
mirostat: params.mirostat,
...(params.mirostatTau !== null
? { mirostat_tau: params.mirostatTau }
: {}),
...(params.mirostatEta !== null
? { mirostat_eta: params.mirostatEta }
: {}),
}
: {}),
// DRY: multiplier>0 unlocks the 4-field chain.
...(params.dryMultiplier !== null && params.dryMultiplier > 0
? {
dry_multiplier: params.dryMultiplier,
...(params.dryBase !== null
? { dry_base: params.dryBase }
: {}),
...(params.dryAllowedLength !== null
? { dry_allowed_length: params.dryAllowedLength }
: {}),
...(params.dryPenaltyLastN !== null
? { dry_penalty_last_n: params.dryPenaltyLastN }
: {}),
}
: {}),
// XTC: probability>0 unlocks threshold.
...(params.xtcProbability !== null && params.xtcProbability > 0
? {
xtc_probability: params.xtcProbability,
...(params.xtcThreshold !== null
? { xtc_threshold: params.xtcThreshold }
: {}),
}
: {}),
...(params.minKeep !== null && params.minKeep > 0
? { min_keep: params.minKeep }
: {}),
...(params.ignoreEos === true ? { ignore_eos: true } : {}),
...(params.minTokens !== null && params.minTokens > 0
? { min_tokens: params.minTokens }
: {}),
// Forward only on non-default; per-backend cap-gates wire visibility.
...(params.skipSpecialTokens === false
? { skip_special_tokens: false }
: {}),
...(params.spacesBetweenSpecialTokens === false
? { spaces_between_special_tokens: false }
: {}),
...(params.includeStopStrInOutput === true
? { include_stop_str_in_output: true }
: {}),
...(params.truncatePromptTokens !== null &&
params.truncatePromptTokens > 0
? { truncate_prompt_tokens: params.truncatePromptTokens }
: {}),
...(params.nKeep !== null && params.nKeep !== 0
? { n_keep: params.nKeep }
: {}),
...(params.nProbs !== null && params.nProbs > 0
? { n_probs: params.nProbs }
: {}),
...(params.cachePrompt === false ? { cache_prompt: false } : {}),
...(params.returnTokens === true ? { return_tokens: true } : {}),
...(params.timingsPerToken === true
? { timings_per_token: true }
: {}),
...(params.postSamplingProbs === true
? { post_sampling_probs: true }
: {}),
// Forward only on explicit opt-out (default true on every
// backend; default omit keeps wire-shape stable for users
// who never opened the new settings panel).
...(params.parallelToolCalls === false
? { parallel_tool_calls: false }
: {}),
image_base64: imageBase64, image_base64: imageBase64,
audio_base64: audioBase64, audio_base64: audioBase64,
cancel_id: cancelId, cancel_id: cancelId,

View file

@ -698,7 +698,10 @@ export function ChatPage(): ReactElement {
const provider = externalProvidersForChat.find( const provider = externalProvidersForChat.find(
(p) => p.id === selection.providerId, (p) => p.id === selection.providerId,
); );
const baseCapabilities = getProviderCapabilities(provider?.providerType); const baseCapabilities = getProviderCapabilities(
provider?.providerType,
selection.modelId,
);
if (!baseCapabilities) return baseCapabilities; if (!baseCapabilities) return baseCapabilities;
const anthropicThinkingEnabled = const anthropicThinkingEnabled =
provider?.providerType === "anthropic" && provider?.providerType === "anthropic" &&

View file

@ -87,13 +87,17 @@ import {
type ProviderCapabilities, type ProviderCapabilities,
getExternalMaxOutputTokens, getExternalMaxOutputTokens,
getExternalMinOutputTokens, getExternalMinOutputTokens,
getProviderStopMax,
getServiceTierOptions,
providerSupportsBuiltinCodeExecution, providerSupportsBuiltinCodeExecution,
providerSupportsFastMode, providerSupportsFastMode,
} from "./provider-capabilities"; } from "./provider-capabilities";
import { StopSequencesInput } from "@/components/ui/stop-sequences-input";
import { useChatRuntimeStore } from "./stores/chat-runtime-store"; import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import { ChatMcpServersDialog } from "./chat-mcp-servers-dialog"; import { ChatMcpServersDialog } from "./chat-mcp-servers-dialog";
import { listMcpServers } from "./api/mcp-servers-api"; import { listMcpServers } from "./api/mcp-servers-api";
import type { InferenceParams } from "./types/runtime"; import type { InferenceParams, ServiceTier } from "./types/runtime";
import { Input } from "@/components/ui/input";
export { defaultInferenceParams, type Preset } from "./presets/preset-policy"; export { defaultInferenceParams, type Preset } from "./presets/preset-policy";
export type { InferenceParams } from "./types/runtime"; export type { InferenceParams } from "./types/runtime";
@ -129,21 +133,11 @@ export function InfoHint({ children }: { children: ReactNode }) {
); );
} }
/** /** Editable numeric value display: transparent text-like input that
* Editable numeric value display. * shows formatted display on blur (so "Off"/"Max" labels render) and
* * switches to the raw number on focus. Commits on blur/Enter, reverts
* Renders as a single <input> that *looks* like text by default * on Escape, clamps on commit. Shared by every slider value + the
* transparent background, no border, no ring and only shows a faint * Context Length input. */
* surface tint on hover/focus to signal editability. When unfocused,
* the input shows the formatted display string (`displayValue ?? value`,
* so labels like "Off" / "Max" still render); on focus, it switches to
* the raw numeric value, selects it, and accepts free text input.
* Commit happens on blur or Enter; Escape reverts. The clamp-to-range
* happens on commit so users can type intermediate values without the
* input fighting them mid-keystroke. Single component shared by every
* slider value and the Context Length input so the click-to-edit
* affordance is consistent across the panel.
*/
function snapToStep( function snapToStep(
value: number, value: number,
step: number, step: number,
@ -407,10 +401,14 @@ export function ChatSettingsPanel({
externalProviderType = null, externalProviderType = null,
onReloadModel, onReloadModel,
}: ChatSettingsPanelProps) { }: ChatSettingsPanelProps) {
// For non-external (local) models we show every knob — providerCapabilities const isMobile = useIsMobile();
// is only consulted when `isExternalModel` is true. An external model with an const isGguf = useChatRuntimeStore((s) => s.activeGgufVariant) != null;
// unknown provider falls back to the OpenAI-compat shape via // Local models show every knob (providerCapabilities only consulted
// getProviderCapabilities, so these flags never undercount support. // when isExternalModel; unknown providers fall back to OPENAI_COMPAT_BASE).
// GGUF llama-server honours frequency_penalty/seed/stop/parallel_tool_calls;
// HF transformers path doesn't, so hide on local non-GGUF (stale params
// are harmlessly ignored by the safetensors worker).
const localSamplerSupportsExtras = !isExternalModel ? isGguf : true;
const showTemperature = const showTemperature =
!isExternalModel || Boolean(providerCapabilities?.temperature); !isExternalModel || Boolean(providerCapabilities?.temperature);
const showTopP = !isExternalModel || Boolean(providerCapabilities?.topP); const showTopP = !isExternalModel || Boolean(providerCapabilities?.topP);
@ -420,8 +418,60 @@ export function ChatSettingsPanel({
!isExternalModel || Boolean(providerCapabilities?.repetitionPenalty); !isExternalModel || Boolean(providerCapabilities?.repetitionPenalty);
const showPresencePenalty = const showPresencePenalty =
!isExternalModel || Boolean(providerCapabilities?.presencePenalty); !isExternalModel || Boolean(providerCapabilities?.presencePenalty);
const isMobile = useIsMobile(); const showFrequencyPenalty = isExternalModel
const isGguf = useChatRuntimeStore((s) => s.activeGgufVariant) != null; ? Boolean(providerCapabilities?.frequencyPenalty)
: localSamplerSupportsExtras;
const showSeed = isExternalModel
? Boolean(providerCapabilities?.seed)
: localSamplerSupportsExtras;
const showStop = isExternalModel
? Boolean(providerCapabilities?.stop)
: localSamplerSupportsExtras;
const showServiceTier =
isExternalModel && Boolean(providerCapabilities?.serviceTier);
const showParallelToolCalls = isExternalModel
? Boolean(providerCapabilities?.parallelToolCalls)
: localSamplerSupportsExtras;
// Extended samplers: external uses cap flag, local is GGUF-only.
const capAdv = (k: keyof ProviderCapabilities): boolean =>
isExternalModel
? Boolean(providerCapabilities?.[k])
: localSamplerSupportsExtras;
const advCaps = {
typicalP: capAdv("typicalP"),
topNSigma: capAdv("topNSigma"),
repeatLastN: capAdv("repeatLastN"),
dynatempRange: capAdv("dynatempRange"),
dynatempExponent: capAdv("dynatempExponent"),
mirostat: capAdv("mirostat"),
mirostatTau: capAdv("mirostatTau"),
mirostatEta: capAdv("mirostatEta"),
topA: capAdv("topA"),
dryMultiplier: capAdv("dryMultiplier"),
dryBase: capAdv("dryBase"),
dryAllowedLength: capAdv("dryAllowedLength"),
dryPenaltyLastN: capAdv("dryPenaltyLastN"),
xtcProbability: capAdv("xtcProbability"),
xtcThreshold: capAdv("xtcThreshold"),
minKeep: capAdv("minKeep"),
ignoreEos: capAdv("ignoreEos"),
minTokens: capAdv("minTokens"),
skipSpecialTokens: capAdv("skipSpecialTokens"),
spacesBetweenSpecialTokens: capAdv("spacesBetweenSpecialTokens"),
includeStopStrInOutput: capAdv("includeStopStrInOutput"),
truncatePromptTokens: capAdv("truncatePromptTokens"),
nKeep: capAdv("nKeep"),
nProbs: capAdv("nProbs"),
cachePrompt: capAdv("cachePrompt"),
returnTokens: capAdv("returnTokens"),
timingsPerToken: capAdv("timingsPerToken"),
postSamplingProbs: capAdv("postSamplingProbs"),
};
const showAdvancedSamplingSection = Object.values(advCaps).some(Boolean);
// Per-provider stop cap; backend re-trims on the wire if a stale
// UI sends more than the upstream accepts.
const stopMaxEntries = getProviderStopMax(externalProviderType);
const serviceTierOptions = getServiceTierOptions(externalProviderType);
const hasModelContent = const hasModelContent =
!isExternalModel && (isGguf || Boolean(params.checkpoint)); !isExternalModel && (isGguf || Boolean(params.checkpoint));
const speculativeType = useChatRuntimeStore((s) => s.speculativeType); const speculativeType = useChatRuntimeStore((s) => s.speculativeType);
@ -1291,6 +1341,143 @@ export function ChatSettingsPanel({
info="Penalizes any token that has already appeared at least once, encouraging the model to introduce new topics. 0 = off." info="Penalizes any token that has already appeared at least once, encouraging the model to introduce new topics. 0 = off."
/> />
) : null} ) : null}
{showFrequencyPenalty ? (
<ParamSlider
label="Frequency Penalty"
value={params.frequencyPenalty}
min={-2}
max={2}
step={0.1}
onChange={set("frequencyPenalty")}
displayValue={
params.frequencyPenalty === 0 ? "Off" : undefined
}
info="Down-weights tokens proportionally to how often they have already appeared. Negative values encourage repetition. 0 = off. OpenAI Chat Completions only; Anthropic and the OpenAI Responses family ignore it."
/>
) : null}
{showSeed ? (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
Seed
</span>
<InfoHint>
Best-effort determinism. OpenAI Chat and OAI-compat
local backends honor it; OpenAI Responses and Anthropic
silently drop it.
</InfoHint>
</div>
<Input
type="number"
inputMode="numeric"
value={params.seed ?? ""}
placeholder="Random"
onChange={(event) => {
const raw = event.target.value;
if (raw === "") {
set("seed")(null);
return;
}
const parsed = Number.parseInt(raw, 10);
if (Number.isFinite(parsed)) {
set("seed")(parsed);
}
}}
className="h-8 w-[124px] shrink-0 text-right font-mono text-xs"
aria-label="Seed"
/>
</div>
) : null}
{showStop ? (
<div className="flex flex-col gap-1.5">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
Stop sequences
</span>
<InfoHint>
Strings that halt generation. Enter or comma to commit.
Maps to `stop_sequences` (Anthropic) / `stop` (OpenAI,
cap 4).
</InfoHint>
</div>
<StopSequencesInput
value={params.stop}
onChange={set("stop")}
maxEntries={stopMaxEntries}
aria-label="Stop sequences"
/>
</div>
) : null}
{showServiceTier ? (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
Service tier
</span>
<InfoHint>
Provider routing tier. `auto` = provider default.
OpenAI: flex / priority / scale. Anthropic:
`standard_only` opts out of Priority Tier.
</InfoHint>
</div>
<Select
value={
// Fall back to "auto" when the persisted tier is not
// legal for the active provider (e.g. "priority" saved
// on OpenAI, then user switched to Anthropic which only
// accepts auto|standard_only). Without this Radix Select
// shows a blank trigger.
params.serviceTier &&
(serviceTierOptions as readonly ServiceTier[]).includes(
params.serviceTier,
)
? params.serviceTier
: "auto"
}
onValueChange={(value) => {
// Store "auto" verbatim: Anthropic distinguishes
// omitted (provider default) from auto (Priority Tier opt-in).
const allowed: readonly ServiceTier[] = serviceTierOptions;
if (allowed.includes(value as ServiceTier)) {
set("serviceTier")(value as ServiceTier);
}
}}
>
<SelectTrigger
className="panel-select-trigger h-8 w-[140px] shrink-0"
aria-label="Service tier"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{serviceTierOptions.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
{showParallelToolCalls ? (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
Parallel tool calls
</span>
<InfoHint>
Allow multiple tool calls per turn (default).
Anthropic uses inverse `disable_parallel_tool_use`.
</InfoHint>
</div>
<Switch
className="panel-switch shrink-0"
checked={params.parallelToolCalls}
onCheckedChange={set("parallelToolCalls")}
aria-label="Parallel tool calls"
/>
</div>
) : null}
{!isExternalModel && !isGguf && ( {!isExternalModel && !isGguf && (
<ParamSlider <ParamSlider
label="Max Seq Length" label="Max Seq Length"
@ -1299,7 +1486,7 @@ export function ChatSettingsPanel({
max={32768} max={32768}
step={128} step={128}
onChange={set("maxSeqLength")} onChange={set("maxSeqLength")}
info="Maximum context window size in tokens — input prompt plus generated output combined. Capped by the model's trained limit." info="Maximum context window in tokens (prompt plus generated output). Capped by the model's trained limit."
/> />
)} )}
<ParamSlider <ParamSlider
@ -1334,6 +1521,503 @@ export function ChatSettingsPanel({
</div> </div>
</CollapsibleSection> </CollapsibleSection>
{showAdvancedSamplingSection ? (
<CollapsibleSection label="Advanced Sampling" defaultOpen={false}>
<div className="flex flex-col gap-5 pt-1">
{advCaps.typicalP ? (
<ParamSlider
label="Typical P"
value={params.typicalP ?? 1}
min={0}
max={1}
step={0.05}
onChange={(v) =>
set("typicalP")(v >= 1 ? null : v)
}
displayValue={
params.typicalP == null || params.typicalP >= 1
? "Off"
: undefined
}
info="llama.cpp `typ_p`. Locally typical sampling. 1.0 = off."
/>
) : null}
{advCaps.topNSigma ? (
<ParamSlider
label="Top N Sigma"
value={params.topNSigma ?? -1}
min={-1}
max={5}
step={0.1}
onChange={(v) =>
set("topNSigma")(v <= -1 ? null : v)
}
displayValue={
params.topNSigma == null || params.topNSigma <= -1
? "Off"
: undefined
}
info="llama.cpp `top_n_sigma`. Sigma-based truncation. -1 = off."
/>
) : null}
{advCaps.repeatLastN ? (
<ParamSlider
label="Repeat Last N"
value={params.repeatLastN ?? 0}
min={-1}
max={2048}
step={1}
onChange={(v) =>
set("repeatLastN")(v === 0 ? null : v)
}
displayValue={
params.repeatLastN == null
? "Off"
: params.repeatLastN === -1
? "Ctx"
: undefined
}
info="llama.cpp `repeat_last_n`. Token window the repetition penalty considers. 0 = off, -1 = full context."
/>
) : null}
{advCaps.dynatempRange ? (
<ParamSlider
label="Dynatemp Range"
value={params.dynatempRange ?? 0}
min={0}
max={5}
step={0.1}
onChange={(v) =>
set("dynatempRange")(v === 0 ? null : v)
}
displayValue={
params.dynatempRange == null || params.dynatempRange === 0
? "Off"
: undefined
}
info="llama.cpp `dynatemp_range`. Dynamic temperature swing around base temperature. 0 = off."
/>
) : null}
{advCaps.dynatempExponent ? (
<ParamSlider
label="Dynatemp Exponent"
value={params.dynatempExponent ?? 1}
min={0}
max={5}
step={0.1}
onChange={(v) => set("dynatempExponent")(v)}
info="llama.cpp `dynatemp_exponent`. Curve exponent, pairs with Dynatemp Range."
/>
) : null}
{advCaps.mirostat ? (
<ParamSlider
label="Mirostat"
value={params.mirostat ?? 0}
min={0}
max={2}
step={1}
onChange={(v) =>
set("mirostat")(v === 0 ? null : v)
}
displayValue={
params.mirostat == null || params.mirostat === 0
? "Off"
: params.mirostat === 1
? "v1"
: "v2"
}
info="llama.cpp `mirostat`. Target-entropy sampler. 0 = off, 1 = Mirostat v1, 2 = Mirostat v2."
/>
) : null}
{advCaps.mirostatTau ? (
<ParamSlider
label="Mirostat Tau"
value={params.mirostatTau ?? 5}
min={0}
max={10}
step={0.1}
onChange={(v) => set("mirostatTau")(v)}
info="llama.cpp `mirostat_tau`. Target entropy. Higher = more diverse."
/>
) : null}
{advCaps.mirostatEta ? (
<ParamSlider
label="Mirostat Eta"
value={params.mirostatEta ?? 0.1}
min={0}
max={1}
step={0.01}
onChange={(v) => set("mirostatEta")(v)}
info="llama.cpp `mirostat_eta`. Learning rate for the entropy controller."
/>
) : null}
{advCaps.topA ? (
<ParamSlider
label="Top A"
value={params.topA ?? 0}
min={0}
max={1}
step={0.05}
onChange={(v) =>
set("topA")(v === 0 ? null : v)
}
displayValue={
params.topA == null || params.topA === 0 ? "Off" : undefined
}
info="OpenRouter `top_a`. Tail-cut sampler scaled by the top token's probability. 0 = off."
/>
) : null}
{advCaps.dryMultiplier ? (
<ParamSlider
label="DRY Multiplier"
value={params.dryMultiplier ?? 0}
min={0}
max={3}
step={0.1}
onChange={(v) =>
set("dryMultiplier")(v === 0 ? null : v)
}
displayValue={
params.dryMultiplier == null || params.dryMultiplier === 0
? "Off"
: undefined
}
info="llama.cpp DRY master switch (unlocks base / allowed length / penalty last N). 0 = off."
/>
) : null}
{advCaps.dryBase && (params.dryMultiplier ?? 0) > 0 ? (
<ParamSlider
label="DRY Base"
value={params.dryBase ?? 1.75}
min={0}
max={5}
step={0.05}
onChange={(v) => set("dryBase")(v)}
info="llama.cpp `dry_base`. Exponential base for the DRY penalty. Default 1.75."
/>
) : null}
{advCaps.dryAllowedLength && (params.dryMultiplier ?? 0) > 0 ? (
<ParamSlider
label="DRY Allowed Length"
value={params.dryAllowedLength ?? 2}
min={0}
max={20}
step={1}
onChange={(v) => set("dryAllowedLength")(v)}
info="llama.cpp `dry_allowed_length`. Repeats up to this length are not penalised. Default 2."
/>
) : null}
{advCaps.dryPenaltyLastN && (params.dryMultiplier ?? 0) > 0 ? (
<ParamSlider
label="DRY Penalty Last N"
value={params.dryPenaltyLastN ?? 0}
min={-1}
max={2048}
step={1}
onChange={(v) =>
set("dryPenaltyLastN")(v === 0 ? null : v)
}
displayValue={
params.dryPenaltyLastN == null
? "Off"
: params.dryPenaltyLastN === -1
? "Ctx"
: undefined
}
info="llama.cpp `dry_penalty_last_n`. Token window the DRY penalty considers. 0 = off, -1 = full context."
/>
) : null}
{advCaps.xtcProbability ? (
<ParamSlider
label="XTC Probability"
value={params.xtcProbability ?? 0}
min={0}
max={1}
step={0.01}
onChange={(v) =>
set("xtcProbability")(v === 0 ? null : v)
}
displayValue={
params.xtcProbability == null || params.xtcProbability === 0
? "Off"
: undefined
}
info="llama.cpp XTC (eXclude Top Choices). Master switch. 0 = off."
/>
) : null}
{advCaps.xtcThreshold && (params.xtcProbability ?? 0) > 0 ? (
<ParamSlider
label="XTC Threshold"
value={params.xtcThreshold ?? 0.1}
min={0}
max={1}
step={0.01}
onChange={(v) => set("xtcThreshold")(v)}
info="llama.cpp `xtc_threshold`. Minimum probability for a token to be removable by XTC. Default 0.1."
/>
) : null}
{advCaps.minKeep ? (
<ParamSlider
label="Min Keep"
value={params.minKeep ?? 0}
min={0}
max={10}
step={1}
onChange={(v) =>
set("minKeep")(v === 0 ? null : v)
}
displayValue={
params.minKeep == null || params.minKeep === 0
? "Off"
: undefined
}
info="llama.cpp `min_keep`. Minimum tokens retained past all sampler filters."
/>
) : null}
{advCaps.minTokens ? (
<ParamSlider
label="Min Tokens"
value={params.minTokens ?? 0}
min={0}
max={512}
step={1}
onChange={(v) =>
set("minTokens")(v === 0 ? null : v)
}
displayValue={
params.minTokens == null || params.minTokens === 0
? "Off"
: undefined
}
info="llama.cpp + vLLM. Minimum tokens before stop / EOS can fire."
/>
) : null}
{advCaps.truncatePromptTokens ? (
<ParamSlider
label="Truncate Prompt"
value={params.truncatePromptTokens ?? 0}
min={0}
max={32768}
step={64}
onChange={(v) =>
set("truncatePromptTokens")(v === 0 ? null : v)
}
displayValue={
params.truncatePromptTokens == null ||
params.truncatePromptTokens === 0
? "Off"
: undefined
}
info="vLLM `truncate_prompt_tokens`. Left-truncate the prompt to this many tokens. 0 = off."
/>
) : null}
{advCaps.nKeep ? (
<ParamSlider
label="N Keep"
value={params.nKeep ?? 0}
min={-1}
max={1024}
step={1}
onChange={(v) =>
set("nKeep")(v === 0 ? null : v)
}
displayValue={
params.nKeep == null
? "Off"
: params.nKeep === -1
? "All"
: undefined
}
info="llama.cpp `n_keep`. Tokens to retain when the context is shifted. 0 = off, -1 = keep all."
/>
) : null}
{advCaps.nProbs ? (
<ParamSlider
label="N Probs"
value={params.nProbs ?? 0}
min={0}
max={20}
step={1}
onChange={(v) =>
set("nProbs")(v === 0 ? null : v)
}
displayValue={
params.nProbs == null || params.nProbs === 0
? "Off"
: undefined
}
info="llama.cpp `n_probs`. Return the top-N token probabilities per token (diagnostic)."
/>
) : null}
{advCaps.ignoreEos ? (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
Ignore EOS
</span>
<InfoHint>
llama.cpp + vLLM. Keep generating past the model's
end-of-sequence token. Useful for forcing long replies.
</InfoHint>
</div>
<Switch
className="panel-switch shrink-0"
checked={Boolean(params.ignoreEos)}
onCheckedChange={(v) => set("ignoreEos")(v ? true : null)}
aria-label="Ignore EOS"
/>
</div>
) : null}
{advCaps.skipSpecialTokens ? (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
Skip Special Tokens
</span>
<InfoHint>
vLLM `skip_special_tokens` (default on). Off keeps
chat-template markers like `&lt;|im_end|&gt;` in output.
</InfoHint>
</div>
<Switch
className="panel-switch shrink-0"
checked={params.skipSpecialTokens ?? true}
onCheckedChange={(v) =>
set("skipSpecialTokens")(v ? null : false)
}
aria-label="Skip special tokens"
/>
</div>
) : null}
{advCaps.spacesBetweenSpecialTokens ? (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
Spaces Between Special Tokens
</span>
<InfoHint>
vLLM `spaces_between_special_tokens`. Default on.
</InfoHint>
</div>
<Switch
className="panel-switch shrink-0"
checked={params.spacesBetweenSpecialTokens ?? true}
onCheckedChange={(v) =>
set("spacesBetweenSpecialTokens")(v ? null : false)
}
aria-label="Spaces between special tokens"
/>
</div>
) : null}
{advCaps.includeStopStrInOutput ? (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
Include Stop String
</span>
<InfoHint>
vLLM `include_stop_str_in_output`. Echo the matched stop
string back in the response (useful for agentic tools).
</InfoHint>
</div>
<Switch
className="panel-switch shrink-0"
checked={Boolean(params.includeStopStrInOutput)}
onCheckedChange={(v) =>
set("includeStopStrInOutput")(v ? true : null)
}
aria-label="Include stop string in output"
/>
</div>
) : null}
{advCaps.cachePrompt ? (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
Cache Prompt
</span>
<InfoHint>
llama.cpp `cache_prompt`. Default on. Reuses the KV cache
across requests with shared prefixes.
</InfoHint>
</div>
<Switch
className="panel-switch shrink-0"
checked={params.cachePrompt ?? true}
onCheckedChange={(v) =>
set("cachePrompt")(v ? null : false)
}
aria-label="Cache prompt"
/>
</div>
) : null}
{advCaps.returnTokens ? (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
Return Tokens
</span>
<InfoHint>
llama.cpp `return_tokens`. Include the raw token ids in
the response (debug).
</InfoHint>
</div>
<Switch
className="panel-switch shrink-0"
checked={Boolean(params.returnTokens)}
onCheckedChange={(v) =>
set("returnTokens")(v ? true : null)
}
aria-label="Return tokens"
/>
</div>
) : null}
{advCaps.timingsPerToken ? (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
Timings Per Token
</span>
<InfoHint>
llama.cpp `timings_per_token`. Per-token wall-clock
timings in the response (perf debug).
</InfoHint>
</div>
<Switch
className="panel-switch shrink-0"
checked={Boolean(params.timingsPerToken)}
onCheckedChange={(v) =>
set("timingsPerToken")(v ? true : null)
}
aria-label="Timings per token"
/>
</div>
) : null}
{advCaps.postSamplingProbs ? (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
Post-Sampling Probs
</span>
<InfoHint>
llama.cpp `post_sampling_probs`. Report the
post-sampling distribution (sampler debug).
</InfoHint>
</div>
<Switch
className="panel-switch shrink-0"
checked={Boolean(params.postSamplingProbs)}
onCheckedChange={(v) =>
set("postSamplingProbs")(v ? true : null)
}
aria-label="Post-sampling probs"
/>
</div>
) : null}
</div>
</CollapsibleSection>
) : null}
{!isExternalModel ? ( {!isExternalModel ? (
<CollapsibleSection label="Tools"> <CollapsibleSection label="Tools">
<div className="flex flex-col gap-5 pt-1"> <div className="flex flex-col gap-5 pt-1">

View file

@ -13,6 +13,11 @@ export interface Preset {
params: InferenceParams; params: InferenceParams;
} }
// Fields that belong to a preset. Sampling knobs are included so a
// user can save a preset that fixes their preferred decoding style.
// Operational knobs (serviceTier, parallelToolCalls) and per-request
// determinism state (seed) are intentionally excluded so switching
// presets does not silently change request routing.
export type PresetOwnedParams = Pick< export type PresetOwnedParams = Pick<
InferenceParams, InferenceParams,
| "temperature" | "temperature"
@ -21,6 +26,8 @@ export type PresetOwnedParams = Pick<
| "minP" | "minP"
| "repetitionPenalty" | "repetitionPenalty"
| "presencePenalty" | "presencePenalty"
| "frequencyPenalty"
| "stop"
| "maxTokens" | "maxTokens"
| "systemPrompt" | "systemPrompt"
>; >;
@ -103,11 +110,22 @@ export function getPresetOwnedParams(
minP: params.minP, minP: params.minP,
repetitionPenalty: params.repetitionPenalty, repetitionPenalty: params.repetitionPenalty,
presencePenalty: params.presencePenalty, presencePenalty: params.presencePenalty,
frequencyPenalty: params.frequencyPenalty,
stop: params.stop,
maxTokens: params.maxTokens, maxTokens: params.maxTokens,
systemPrompt: params.systemPrompt, systemPrompt: params.systemPrompt,
}; };
} }
function stopArraysEqual(a: string[], b: string[]): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false;
}
return true;
}
export function isSamePresetConfig( export function isSamePresetConfig(
a: InferenceParams, a: InferenceParams,
b: InferenceParams, b: InferenceParams,
@ -121,6 +139,8 @@ export function isSamePresetConfig(
left.minP === right.minP && left.minP === right.minP &&
left.repetitionPenalty === right.repetitionPenalty && left.repetitionPenalty === right.repetitionPenalty &&
left.presencePenalty === right.presencePenalty && left.presencePenalty === right.presencePenalty &&
left.frequencyPenalty === right.frequencyPenalty &&
stopArraysEqual(left.stop, right.stop) &&
left.maxTokens === right.maxTokens && left.maxTokens === right.maxTokens &&
left.systemPrompt === right.systemPrompt left.systemPrompt === right.systemPrompt
); );

File diff suppressed because it is too large Load diff

View file

@ -406,11 +406,45 @@ const PERSISTED_INFERENCE_PARAM_KEYS = [
"minP", "minP",
"repetitionPenalty", "repetitionPenalty",
"presencePenalty", "presencePenalty",
"frequencyPenalty",
"seed",
"stop",
"serviceTier",
"parallelToolCalls",
"maxSeqLength", "maxSeqLength",
"maxTokens", "maxTokens",
"systemPrompt", "systemPrompt",
"trustRemoteCode", "trustRemoteCode",
"fastMode", "fastMode",
// Extended llama.cpp / vLLM / OpenRouter samplers exposed by PR #5711.
"typicalP",
"topNSigma",
"repeatLastN",
"dynatempRange",
"dynatempExponent",
"mirostat",
"mirostatTau",
"mirostatEta",
"topA",
"dryMultiplier",
"dryBase",
"dryAllowedLength",
"dryPenaltyLastN",
"xtcProbability",
"xtcThreshold",
"minKeep",
"ignoreEos",
"minTokens",
"skipSpecialTokens",
"spacesBetweenSpecialTokens",
"includeStopStrInOutput",
"truncatePromptTokens",
"nKeep",
"nProbs",
"cachePrompt",
"returnTokens",
"timingsPerToken",
"postSamplingProbs",
] as const satisfies readonly PersistedInferenceParamKey[]; ] as const satisfies readonly PersistedInferenceParamKey[];
const SCALAR_SETTING_KEYS = [ const SCALAR_SETTING_KEYS = [

View file

@ -314,12 +314,81 @@ export interface OpenAIChatCompletionsRequest {
* the Anthropic provider with `code_execution` in `enabled_tools`. * the Anthropic provider with `code_execution` in `enabled_tools`.
*/ */
anthropic_code_exec_container_id?: string | null; anthropic_code_exec_container_id?: string | null;
/** /** OpenAI Chat only. Range -2..2. */
* Anthropic fast-mode toggle. Opus 4.6 / 4.7 only; backend drops frequency_penalty?: number;
* silently on every other model + provider. See /** OAI Chat + most OAI-compat. Responses + Anthropic drop. */
* https://platform.claude.com/docs/en/build-with-claude/fast-mode seed?: number;
*/ /** OAI Chat caps at 4; Anthropic mapped to `stop_sequences`. */
stop?: string[];
/** Per-provider enum (see getServiceTierOptions); external_provider.py drops unsupported values. */
service_tier?:
| "auto"
| "default"
| "flex"
| "priority"
| "scale"
| "standard_only";
/** Anthropic inverts to `disable_parallel_tool_use`. */
parallel_tool_calls?: boolean;
/** llama.cpp `typ_p`. 1.0 disables. */
typical_p?: number;
/** llama.cpp `top_n_sigma`. -1 disables. */
top_n_sigma?: number;
/** llama.cpp `repeat_last_n`. 0 disables, -1 = ctx-size. */
repeat_last_n?: number;
/** llama.cpp `dynatemp_range`. 0 disables. */
dynatemp_range?: number;
/** llama.cpp `dynatemp_exponent`. Pairs with dynatemp_range. */
dynatemp_exponent?: number;
/** llama.cpp `mirostat` (0/1/2). 0 disables. */
mirostat?: number;
mirostat_tau?: number;
mirostat_eta?: number;
/** OpenRouter `top_a`. https://openrouter.ai/docs/api/reference/parameters */
top_a?: number;
/** Anthropic Opus 4.6 / 4.7 only. https://platform.claude.com/docs/en/build-with-claude/fast-mode */
fast_mode?: boolean | null; fast_mode?: boolean | null;
/**
* llama.cpp DRY sampler (4 fields). `dry_multiplier=0` disables.
* https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md
*/
dry_multiplier?: number;
/** Default 1.75. */
dry_base?: number;
/** Default 2. */
dry_allowed_length?: number;
/** 0 disables, -1 = ctx-size. */
dry_penalty_last_n?: number;
/** llama.cpp XTC. 0 disables. */
xtc_probability?: number;
/** Default 0.1. */
xtc_threshold?: number;
/** llama.cpp `min_keep`. */
min_keep?: number;
/** Continue past EOS. llama.cpp + vLLM. */
ignore_eos?: boolean;
/** Min tokens before stop / EOS. llama.cpp + vLLM. */
min_tokens?: number;
/** vLLM only. */
skip_special_tokens?: boolean;
/** vLLM only. */
spaces_between_special_tokens?: boolean;
/** vLLM only. Useful for agentic tools. */
include_stop_str_in_output?: boolean;
/** vLLM only. Left-truncate the prompt. */
truncate_prompt_tokens?: number;
/** llama.cpp `n_keep`. -1 = keep all. */
n_keep?: number;
/** llama.cpp `n_probs`. */
n_probs?: number;
/** llama.cpp `cache_prompt`. */
cache_prompt?: boolean;
/** llama.cpp `return_tokens` (debug). */
return_tokens?: boolean;
/** llama.cpp `timings_per_token` (perf debug). */
timings_per_token?: boolean;
/** llama.cpp `post_sampling_probs` (sampler debug). */
post_sampling_probs?: boolean;
} }
export interface OpenAIChatDelta { export interface OpenAIChatDelta {

View file

@ -1,6 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export type ServiceTier =
| "auto"
| "default"
| "flex"
| "priority"
| "scale"
| "standard_only";
// null = field omitted from wire (provider default).
// Per-provider gates in provider-capabilities.ts.
export interface InferenceParams { export interface InferenceParams {
temperature: number; temperature: number;
topP: number; topP: number;
@ -8,17 +18,77 @@ export interface InferenceParams {
minP: number; minP: number;
repetitionPenalty: number; repetitionPenalty: number;
presencePenalty: number; presencePenalty: number;
/** OpenAI Chat only; rejected by Responses + Anthropic. */
frequencyPenalty: number;
/** Determinism seed. OpenAI Chat + most OAI-compat backends only. */
seed: number | null;
/** OAI Chat `stop` / Anthropic `stop_sequences`. OAI caps at 4. */
stop: string[];
/** Per-provider enum via `getServiceTierOptions`. `null` = provider default. */
serviceTier: ServiceTier | null;
/** Anthropic inverts to `disable_parallel_tool_use`. */
parallelToolCalls: boolean;
/** llama.cpp `typ_p`. 1.0 disables. */
typicalP: number | null;
/** llama.cpp `top_n_sigma`. -1 disables. */
topNSigma: number | null;
/** llama.cpp `repeat_last_n`. 0 disables, -1 = ctx-size. */
repeatLastN: number | null;
/** llama.cpp `dynatemp_range`. 0 disables. */
dynatempRange: number | null;
/** llama.cpp `dynatemp_exponent`. Pairs with dynatempRange. */
dynatempExponent: number | null;
/** llama.cpp `mirostat` (0/1/2). 0 disables. */
mirostat: number | null;
mirostatTau: number | null;
mirostatEta: number | null;
/** OpenRouter `top_a`. Range [0, 1]. */
topA: number | null;
/** llama.cpp DRY: multiplier is master switch (0 disables 4-field chain). See llama.cpp/tools/server/README.md. */
dryMultiplier: number | null;
/** Default 1.75. */
dryBase: number | null;
/** Default 2. */
dryAllowedLength: number | null;
/** 0 disables, -1 = ctx-size. */
dryPenaltyLastN: number | null;
/** llama.cpp XTC: probability is the master switch (0 disables). */
xtcProbability: number | null;
/** Default 0.1. */
xtcThreshold: number | null;
/** llama.cpp `min_keep`: min tokens past all filters. 0 disables. */
minKeep: number | null;
/** Continue past EOS. llama.cpp + vLLM. */
ignoreEos: boolean | null;
/** Min tokens before stop / EOS can fire. llama.cpp + vLLM. */
minTokens: number | null;
/** vLLM only. Default true; forward only when false. */
skipSpecialTokens: boolean | null;
/** vLLM only. Default true; forward only when false. */
spacesBetweenSpecialTokens: boolean | null;
/** vLLM only. Useful for agentic tools needing the matched stop string echoed. */
includeStopStrInOutput: boolean | null;
/** vLLM only. Left-truncate the prompt. */
truncatePromptTokens: number | null;
/** llama.cpp `n_keep`. 0 disables, -1 = keep all. */
nKeep: number | null;
/** llama.cpp `n_probs`: top-N token probabilities per token. */
nProbs: number | null;
/** llama.cpp `cache_prompt`. Default true; forward only when false. */
cachePrompt: boolean | null;
/** llama.cpp `return_tokens` (debug). */
returnTokens: boolean | null;
/** llama.cpp `timings_per_token` (perf debug). */
timingsPerToken: boolean | null;
/** llama.cpp `post_sampling_probs` (sampler debug). */
postSamplingProbs: boolean | null;
maxSeqLength: number; maxSeqLength: number;
maxTokens: number; maxTokens: number;
systemPrompt: string; systemPrompt: string;
checkpoint: string; checkpoint: string;
/** Allow loading models with custom code (e.g. NVIDIA Nemotron). Only enable for repos you trust. */ /** Trust custom model code (e.g. NVIDIA Nemotron). Only for trusted repos. */
trustRemoteCode?: boolean; trustRemoteCode?: boolean;
/** /** Anthropic Opus 4.6 / 4.7 only. 6x pricing for higher OTPS. */
* Anthropic fast-mode toggle. Opus 4.6 / 4.7 only; higher OTPS at
* 6x standard Opus pricing. Default false.
* https://platform.claude.com/docs/en/build-with-claude/fast-mode
*/
fastMode?: boolean; fastMode?: boolean;
} }
@ -29,6 +99,39 @@ export const DEFAULT_INFERENCE_PARAMS: InferenceParams = {
minP: 0.01, minP: 0.01,
repetitionPenalty: 1.0, repetitionPenalty: 1.0,
presencePenalty: 0.0, presencePenalty: 0.0,
frequencyPenalty: 0.0,
seed: null,
stop: [],
serviceTier: null,
parallelToolCalls: true,
typicalP: null,
topNSigma: null,
repeatLastN: null,
dynatempRange: null,
dynatempExponent: null,
mirostat: null,
mirostatTau: null,
mirostatEta: null,
topA: null,
dryMultiplier: null,
dryBase: null,
dryAllowedLength: null,
dryPenaltyLastN: null,
xtcProbability: null,
xtcThreshold: null,
minKeep: null,
ignoreEos: null,
minTokens: null,
skipSpecialTokens: null,
spacesBetweenSpecialTokens: null,
includeStopStrInOutput: null,
truncatePromptTokens: null,
nKeep: null,
nProbs: null,
cachePrompt: null,
returnTokens: null,
timingsPerToken: null,
postSamplingProbs: null,
maxSeqLength: 4096, maxSeqLength: 4096,
maxTokens: 8192, maxTokens: 8192,
systemPrompt: "", systemPrompt: "",

View file

@ -40,10 +40,23 @@ const NUMERIC_INFERENCE_FIELDS = [
"minP", "minP",
"repetitionPenalty", "repetitionPenalty",
"presencePenalty", "presencePenalty",
"frequencyPenalty",
"maxSeqLength", "maxSeqLength",
"maxTokens", "maxTokens",
] as const satisfies readonly (keyof PersistedInferenceParams)[]; ] as const satisfies readonly (keyof PersistedInferenceParams)[];
// `seed` is nullable so it skips NUMERIC_INFERENCE_FIELDS' finite-number filter.
// Keep in sync with ServiceTier (../types/runtime.ts) and getServiceTierOptions.
// "scale" stays for forward-compat with legacy persisted data.
const VALID_SERVICE_TIERS = new Set([
"auto",
"default",
"flex",
"priority",
"scale",
"standard_only",
]);
const CHAT_PRESET_SOURCES = new Set<string>([ const CHAT_PRESET_SOURCES = new Set<string>([
"builtin-default", "builtin-default",
"custom", "custom",
@ -140,6 +153,87 @@ function sanitizeInferenceParams(
if (typeof value.trustRemoteCode === "boolean") { if (typeof value.trustRemoteCode === "boolean") {
params.trustRemoteCode = value.trustRemoteCode; params.trustRemoteCode = value.trustRemoteCode;
} }
// seed: nullable integer (null = no seed on the wire).
if (value.seed === null) {
params.seed = null;
} else if (typeof value.seed === "number" && Number.isInteger(value.seed)) {
params.seed = value.seed;
}
// stop: cap at 16 (Anthropic widest); backend re-truncates per provider.
// Empty array MUST persist or clearing the last chip is reverted on reload.
if (Array.isArray(value.stop)) {
const stops = value.stop.filter((s): s is string => typeof s === "string");
params.stop = stops.slice(0, 16);
}
if (value.serviceTier === null) {
params.serviceTier = null;
} else if (
typeof value.serviceTier === "string" &&
VALID_SERVICE_TIERS.has(value.serviceTier)
) {
params.serviceTier = value.serviceTier as PersistedInferenceParams["serviceTier"];
}
if (typeof value.parallelToolCalls === "boolean") {
params.parallelToolCalls = value.parallelToolCalls;
}
// typicalP: nullable float (null = no typ_p on wire, matches
// llama-server default 1.0). Mirrors seed handling.
if (value.typicalP === null) {
params.typicalP = null;
} else if (
typeof value.typicalP === "number" &&
Number.isFinite(value.typicalP)
) {
params.typicalP = value.typicalP;
}
// Nullable numeric samplers (same handling as typicalP/seed).
for (const key of [
"topNSigma",
"repeatLastN",
"dynatempRange",
"dynatempExponent",
"mirostat",
"mirostatTau",
"mirostatEta",
"topA",
"dryMultiplier",
"dryBase",
"dryAllowedLength",
"dryPenaltyLastN",
"xtcProbability",
"xtcThreshold",
"minKeep",
"minTokens",
"truncatePromptTokens",
"nKeep",
"nProbs",
] as const) {
const raw = value[key];
if (raw === null) {
(params as Record<string, unknown>)[key] = null;
} else if (typeof raw === "number" && Number.isFinite(raw)) {
(params as Record<string, unknown>)[key] = raw;
}
}
// Nullable booleans (ignoreEos, skip/spaces special-tokens, include-stop,
// cache_prompt, return_tokens, timings_per_token, post_sampling_probs).
for (const key of [
"ignoreEos",
"skipSpecialTokens",
"spacesBetweenSpecialTokens",
"includeStopStrInOutput",
"cachePrompt",
"returnTokens",
"timingsPerToken",
"postSamplingProbs",
] as const) {
const raw = value[key];
if (raw === null) {
(params as Record<string, unknown>)[key] = null;
} else if (typeof raw === "boolean") {
(params as Record<string, unknown>)[key] = raw;
}
}
// Mirror trustRemoteCode handling so the toggle survives reload // Mirror trustRemoteCode handling so the toggle survives reload
// and the /api/chat/settings round-trip. // and the /api/chat/settings round-trip.
if (typeof value.fastMode === "boolean") { if (typeof value.fastMode === "boolean") {