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.
This commit is contained in:
Daniel Han 2026-05-26 05:46:14 +00:00
commit 643c0a88f1
3 changed files with 106 additions and 24 deletions

View file

@ -1190,6 +1190,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
);
const externalCapabilities = getProviderCapabilities(
externalProvider?.providerType,
externalSelection?.modelId,
);
const externalReasoningCaps: ReturnType<
typeof getExternalReasoningCapabilities

View file

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

View file

@ -395,26 +395,87 @@ const ALL_SUPPORTED: ProviderCapabilities = {
parallelToolCalls: true,
};
// Reasoning-class OpenAI models served via /v1/responses fix temperature
// at 1, ignore top_p, and 400 on presence/frequency_penalty / seed. Non
// reasoning models (gpt-4o, gpt-4-turbo, gpt-4, gpt-3.5-turbo) keep the
// full sampling surface even when routed through /v1/responses. See
// https://platform.openai.com/docs/guides/reasoning and the GPT-5 release
// notes; backend dispatch is external_provider._stream_openai_responses.
// The Responses API itself drops `stop`, so we leave that off for all
// OpenAI models regardless of family.
const OPENAI_REASONING_CAPABILITIES: ProviderCapabilities = {
temperature: false,
topP: false,
topK: false,
minP: false,
repetitionPenalty: false,
presencePenalty: false,
frequencyPenalty: false,
seed: false,
stop: false,
serviceTier: true,
parallelToolCalls: true,
};
const OPENAI_CHAT_CAPABILITIES: ProviderCapabilities = {
temperature: true,
topP: true,
topK: false,
minP: false,
repetitionPenalty: false,
presencePenalty: true,
frequencyPenalty: true,
seed: true,
// Responses API does not surface `stop` even for non-reasoning models;
// tracked in OpenAI's Responses-vs-ChatCompletions migration notes.
stop: false,
serviceTier: true,
parallelToolCalls: true,
};
// Prefix list for OpenAI reasoning-class model ids. Kept in sync with
// OPENAI_REASONING_MODELS below (used for reasoning_effort capability).
// Longest prefixes first so "gpt-5.5-pro" wins over "gpt-5.5".
const OPENAI_REASONING_MODEL_PREFIXES = [
"gpt-5.5-pro",
"gpt-5.5",
"gpt-5.4-pro",
"gpt-5.4",
"gpt-5.3-chat-latest",
"gpt-5.3-codex",
"gpt-5.3",
"gpt-5.2",
"gpt-5.1",
"gpt-5",
"o1",
"o3",
"o4",
] as const;
function isOpenAIReasoningModelId(modelId: string | null | undefined): boolean {
const normalized = modelId?.trim().toLowerCase() ?? "";
if (!normalized) return false;
return OPENAI_REASONING_MODEL_PREFIXES.some((p) => normalized.startsWith(p));
}
// Mirror of backend _ANTHROPIC_4_7_SAMPLING_REMOVED in
// studio/backend/core/inference/external_provider.py:110. Claude 4.7
// (Opus/Sonnet/Haiku) removed temperature, top_p, and top_k entirely;
// surfacing the sliders would let the user move a control that the
// backend silently strips. The trailing -4-7[-.]/EOL anchor keeps future
// families (claude-opus-5 etc.) unaffected.
const ANTHROPIC_4_7_SAMPLING_REMOVED_REGEX = /^claude-(?:opus|sonnet|haiku)-4-7(?:[-.]|$)/i;
function isClaude47SamplingRemoved(modelId: string | null | undefined): boolean {
const normalized = modelId?.trim().toLowerCase() ?? "";
if (!normalized) return false;
return ANTHROPIC_4_7_SAMPLING_REMOVED_REGEX.test(normalized);
}
const PROVIDER_CAPABILITIES: Record<string, ProviderCapabilities> = {
// OpenAI's flagship models (gpt-5.x / o3 / gpt-4.5) are reasoning-class
// models served via /v1/responses, which rejects temperature, top_p, and
// presence/frequency penalty. See backend
// external_provider._stream_openai_responses for the proxy.
// service_tier and parallel_tool_calls are accepted on /v1/responses;
// seed / stop / frequency_penalty are 400'd alongside temperature/top_p.
openai: {
temperature: false,
topP: false,
topK: false,
minP: false,
repetitionPenalty: false,
presencePenalty: false,
frequencyPenalty: false,
seed: false,
stop: false,
serviceTier: true,
parallelToolCalls: true,
},
// Default OpenAI bucket is reasoning-class (current registry only ships
// gpt-5.x / o3 ids), but per-model resolution in getProviderCapabilities
// upgrades non-reasoning ids (gpt-4o etc.) to OPENAI_CHAT_CAPABILITIES.
openai: OPENAI_REASONING_CAPABILITIES,
// Anthropic's Messages API accepts top_k on 3.x and 4.5/4.6, but Claude
// 4.7 (Opus/Sonnet/Haiku) deprecated it and returns 400 if it is set.
// We surface top_k in the panel for all Anthropic providers and let the
@ -496,15 +557,32 @@ const PROVIDER_CAPABILITIES: Record<string, ProviderCapabilities> = {
const DEFAULT_EXTERNAL_CAPABILITIES = OPENAI_COMPAT_BASE;
/**
* Resolve the capability set for an external provider. Returns `null` for
* a local model (i.e. when `providerType` is null/undefined), which callers
* should treat as "every knob applies".
* Resolve the capability set for an external provider, optionally
* specialised by model id. Returns `null` for a local model (i.e. when
* `providerType` is null/undefined), which callers should treat as
* "every knob applies".
*
* Per-model specialisations:
* - openai + non-reasoning model (gpt-4o, gpt-4-turbo, gpt-4,
* gpt-3.5-turbo): full sampling surface (OPENAI_CHAT_CAPABILITIES).
* - openai + reasoning model (gpt-5.x, o1, o3, o4): restrictive
* (OPENAI_REASONING_CAPABILITIES).
* - anthropic + claude-*-4-7: temperature/top_p/top_k stripped to
* match the backend 400-avoidance regex.
*/
export function getProviderCapabilities(
providerType: string | null | undefined,
modelId?: string | null | undefined,
): ProviderCapabilities | null {
if (!providerType) return null;
return PROVIDER_CAPABILITIES[providerType] ?? DEFAULT_EXTERNAL_CAPABILITIES;
const base = PROVIDER_CAPABILITIES[providerType] ?? DEFAULT_EXTERNAL_CAPABILITIES;
if (providerType === "openai" && modelId && !isOpenAIReasoningModelId(modelId)) {
return OPENAI_CHAT_CAPABILITIES;
}
if (providerType === "anthropic" && isClaude47SamplingRemoved(modelId)) {
return { ...base, temperature: false, topP: false, topK: false };
}
return base;
}
const DEFAULT_EFFORT_LEVELS = ["low", "medium", "high"] as const;