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).
This commit is contained in:
Daniel Han 2026-05-24 12:44:38 +00:00
commit d6765fddce
6 changed files with 214 additions and 17 deletions

View file

@ -435,6 +435,11 @@ class ExternalProviderClient:
messages,
model,
max_tokens,
frequency_penalty = frequency_penalty,
seed = seed,
stop = stop,
parallel_tool_calls = parallel_tool_calls,
presence_penalty = presence_penalty,
):
yield line
return
@ -850,6 +855,12 @@ class ExternalProviderClient:
messages: list[dict[str, Any]],
model: str,
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]:
"""
Kimi $web_search round-trip.
@ -889,6 +900,37 @@ class ExternalProviderClient:
if max_tokens is not None:
body["max_tokens"] = max_tokens
# Forward the new optional sampling extensions (#5711) on the
# web-search bypass too. The default OAI-compat body construction
# (which adds these) is skipped because this helper returns
# early; forwarding here ensures kimi-with-search honours the
# same sampling controls as kimi-without-search.
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:
body["seed"] = seed
if stop:
if isinstance(stop, str):
if stop.strip():
body["stop"] = stop
elif isinstance(stop, list):
sequences = list(
dict.fromkeys(s for s in stop if isinstance(s, str) and s)
)
if len(sequences) > 4:
logger.warning(
"stop sequences truncated to 4 entries "
"(received %d, OpenAI's hard cap is 4)",
len(sequences),
)
body["stop"] = sequences[:4]
elif sequences:
body["stop"] = sequences
if parallel_tool_calls is not None:
body["parallel_tool_calls"] = parallel_tool_calls
# Strip body fields the Kimi registry declares unusable
# (temperature/top_p — see body_omit in providers.py).
from core.inference.providers import get_provider_info
@ -2783,12 +2825,18 @@ class ExternalProviderClient:
"input": input_items,
"stream": True,
}
# Responses accepts service_tier on the same enum set as Chat
# Completions minus `scale`. parallel_tool_calls follows the
# same shape (default true). The frontend capability gate
# (provider-capabilities.ts) already filters the option lists
# per provider, so we just forward what we got.
if service_tier in ("auto", "default", "flex", "priority"):
# Responses accepts the same service_tier enum set as Chat
# Completions (auto|default|flex|scale|priority) per the live
# `openai-python` SDK
# (`src/openai/types/responses/response_create_params.py`
# declares `Optional[Literal["auto", "default", "flex",
# "scale", "priority"]]`). parallel_tool_calls follows the same
# shape (default true). The frontend capability gate
# (provider-capabilities.ts) already filters per-provider, so
# we just forward whatever value the dispatcher hands us, with
# `standard_only` (Anthropic-only) being the one value Responses
# has never accepted.
if service_tier in ("auto", "default", "flex", "scale", "priority"):
body["service_tier"] = service_tier
if parallel_tool_calls is not None:
body["parallel_tool_calls"] = bool(parallel_tool_calls)

View file

@ -107,6 +107,18 @@ class ChatInferenceSettings(BaseModel):
minP: Optional[float] = None
repetitionPenalty: Optional[float] = None
presencePenalty: Optional[float] = None
# New per-provider sampling knobs. `extra="forbid"` would 422 any
# settings save from a frontend on the new code if these were not
# listed here, breaking the entire chat-settings persistence path.
# Keep these aligned with `InferenceParams` in
# studio/frontend/src/features/chat/types/runtime.ts.
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
maxTokens: Optional[float] = None
systemPrompt: Optional[str] = None

View file

@ -4898,6 +4898,9 @@ def _build_passthrough_payload(
min_p = None,
repetition_penalty = None,
presence_penalty = None,
frequency_penalty = None,
seed = None,
parallel_tool_calls = None,
tool_choice = "auto",
response_format = None,
chat_template_kwargs = None,
@ -4929,6 +4932,18 @@ def _build_passthrough_payload(
body["repeat_penalty"] = repetition_penalty
if presence_penalty is not None:
body["presence_penalty"] = presence_penalty
# New per-provider sampling extensions (PR #5711). llama-server's
# /v1/chat/completions endpoint accepts the standard OpenAI fields,
# so forward them straight through. parallel_tool_calls is a no-op
# on llama-server today (the upstream always dispatches sequentially)
# but forward it anyway so a future llama-server release that
# implements it picks up the user's preference automatically.
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 response_format is not None:
# llama-server applies a GBNF grammar derived from the JSON schema
# when response_format is present. Field is documented flat at the
@ -5347,6 +5362,9 @@ def _build_openai_passthrough_body(payload, backend_ctx = None) -> dict:
min_p = payload.min_p,
repetition_penalty = payload.repetition_penalty,
presence_penalty = payload.presence_penalty,
frequency_penalty = payload.frequency_penalty,
seed = payload.seed,
parallel_tool_calls = payload.parallel_tool_calls,
tool_choice = tool_choice,
response_format = _extract_response_format(payload),
chat_template_kwargs = tpl_kwargs,

View file

@ -385,11 +385,24 @@ def test_openai_responses_forwards_service_tier(monkeypatch):
assert body.get("service_tier") == "priority", body
def test_openai_responses_rejects_chat_only_service_tier(monkeypatch):
@pytest.mark.parametrize(
"value", ["auto", "default", "flex", "scale", "priority"]
)
def test_openai_responses_accepts_full_service_tier_enum(monkeypatch, value):
"""`openai-python`'s ResponseCreateParams declares
`Optional[Literal["auto", "default", "flex", "scale", "priority"]]`
so every value in that set forwards untouched."""
captured = _install_mock(monkeypatch, sse_payload = _responses_done_payload())
body = _drive_openai_responses(captured, service_tier = "scale")
# Responses only accepts auto|default|flex|priority -- `scale` is
# silently dropped so a stale frontend cannot 400 the request.
body = _drive_openai_responses(captured, service_tier = value)
assert body.get("service_tier") == value, body
def test_openai_responses_drops_anthropic_only_service_tier(monkeypatch):
"""`standard_only` is Anthropic-only and Responses has never accepted
it. Drop it client-side so a stale frontend cannot 400 the request.
"""
captured = _install_mock(monkeypatch, sse_payload = _responses_done_payload())
body = _drive_openai_responses(captured, service_tier = "standard_only")
assert "service_tier" not in body, body
@ -460,3 +473,106 @@ def test_chat_completion_request_clamps_frequency_penalty_range():
"frequency_penalty": -3.0,
}
)
# ── Kimi web-search bypass forwards new sampling fields ────────────────
def test_kimi_web_search_bypass_forwards_new_sampling_fields(monkeypatch):
"""The Kimi `enabled_tools=["web_search"]` path takes an early
return into `_stream_kimi_web_search` BEFORE the default OAI-compat
body builder runs. PR #5711 added new sampling fields to the
default builder; this test pins that the web-search bypass also
forwards them so Kimi-with-search and Kimi-without-search behave
consistently."""
captured = _install_mock(monkeypatch, sse_payload = _oai_done_payload())
async def run():
client = ExternalProviderClient(
provider_type = "kimi",
base_url = "https://api.moonshot.ai/v1",
api_key = "kimi-test",
)
async for _ in client.stream_chat_completion(
messages = [{"role": "user", "content": "hi"}],
model = "kimi-k2.6",
temperature = 1.0,
top_p = 1.0,
max_tokens = 256,
enabled_tools = ["web_search"],
presence_penalty = 0.5,
frequency_penalty = 1.25,
seed = 7,
stop = ["END"],
parallel_tool_calls = False,
):
pass
await client.close()
_drive(run())
body = captured["body"]
assert body.get("frequency_penalty") == 1.25, body
assert body.get("seed") == 7, body
assert body.get("stop") == ["END"], body
assert body.get("parallel_tool_calls") is False, body
assert body.get("presence_penalty") == 0.5, body
# body_omit still strips temperature / top_p for Kimi.
assert "temperature" not in body, body
assert "top_p" not in body, body
# ── Local OpenAI passthrough forwards new sampling fields ──────────────
def test_local_openai_passthrough_forwards_new_sampling_fields():
"""Round 1 reviewers (10/20) flagged that
`_build_openai_passthrough_body` dropped frequency_penalty / seed /
parallel_tool_calls when forwarding to llama-server. Pin the
extended contract."""
from models.inference import ChatCompletionRequest
from routes.inference import _build_openai_passthrough_body
payload = ChatCompletionRequest.model_validate(
{
"messages": [{"role": "user", "content": "hi"}],
"stream": True,
"frequency_penalty": 1.25,
"seed": 123,
"stop": ["END"],
"parallel_tool_calls": False,
}
)
body = _build_openai_passthrough_body(payload, backend_ctx = 4096)
assert body["frequency_penalty"] == 1.25, body
assert body["seed"] == 123, body
assert body["stop"] == ["END"], body
assert body["parallel_tool_calls"] is False, body
# ── Backend ChatInferenceSettings schema accepts new fields ────────────
def test_chat_settings_payload_accepts_new_sampling_keys():
"""Round 1 reviewers flagged that `ChatSettingsPayload.extra="forbid"`
with the old field list 422'd every settings save that contained
any of the new keys. Pin that the new keys round-trip."""
from routes.chat_history import ChatSettingsPayload
parsed = ChatSettingsPayload.model_validate(
{
"inferenceParams": {
"frequencyPenalty": 0.7,
"seed": 42,
"stop": ["END"],
"serviceTier": "standard_only",
"parallelToolCalls": False,
}
}
)
ip = parsed.inferenceParams
assert ip is not None
assert ip.frequencyPenalty == 0.7
assert ip.seed == 42
assert ip.stop == ["END"]
assert ip.serviceTier == "standard_only"
assert ip.parallelToolCalls is False

View file

@ -65,17 +65,19 @@ export type ServiceTierOption =
| "default"
| "flex"
| "priority"
| "scale"
| "standard_only";
/**
* Legal `service_tier` values per provider, sourced from each upstream's
* docs. Anthropic exposes only `auto` and `standard_only`. OpenAI in
* Studio is routed through `/v1/responses` (not Chat Completions), and
* the Responses endpoint only accepts `auto` / `default` / `flex` /
* `priority` `scale` is Chat-only and would be silently dropped here,
* so the option list omits it to avoid misleading the user. Other
* providers fall through to a permissive `auto` / `default` pair so the
* picker stays usable for OpenAI-compat backends.
* Studio is routed through `/v1/responses` (not Chat Completions); the
* live `openai-python` SDK declares the Responses-side service_tier as
* `Literal["auto", "default", "flex", "scale", "priority"]`
* (`src/openai/types/responses/response_create_params.py`), so the
* full set is exposed. Other providers fall through to a permissive
* `auto` / `default` pair so the picker stays usable for
* OpenAI-compat backends.
*/
export function getServiceTierOptions(
providerType: string | null | undefined,
@ -84,7 +86,7 @@ export function getServiceTierOptions(
return ["auto", "standard_only"] as const;
}
if (providerType === "openai") {
return ["auto", "default", "flex", "priority"] as const;
return ["auto", "default", "flex", "scale", "priority"] as const;
}
return ["auto", "default"] as const;
}

View file

@ -6,6 +6,7 @@ export type ServiceTier =
| "default"
| "flex"
| "priority"
| "scale"
| "standard_only";
export interface InferenceParams {