From f40b5fbe75b88e4b14885b284650921b93ed2a8b Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Tue, 12 May 2026 09:22:53 +0400 Subject: [PATCH] studio/chat: drive ChatSettingsPanel from a per-provider capability map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the binary isExternalModel toggle in the sampling section with a provider-aware capability map. Each external provider type advertises which of top_k / min_p / repetition_penalty / presence_penalty its chat-completions API actually accepts, so the panel only renders the knobs that map onto the active provider's request body. Anthropic now exposes top_k; DeepSeek hides presence_penalty (deprecated in their docs); OpenRouter and custom providers continue to show every knob (OpenRouter drops unsupported server-side, custom assumes OpenAI-compat or a permissive vLLM/Ollama backend). Local models are unaffected — null capabilities means 'show everything'. chat-adapter.ts now forwards top_k / presence_penalty to the external proxy only when the active provider's capabilities permit it, so the request body matches what the UI shows. --- .../src/features/chat/api/chat-adapter.ts | 13 ++- .../frontend/src/features/chat/chat-page.tsx | 16 ++- .../src/features/chat/chat-settings-sheet.tsx | 110 +++++++++++------- .../features/chat/provider-capabilities.ts | 83 +++++++++++++ 4 files changed, 176 insertions(+), 46 deletions(-) create mode 100644 studio/frontend/src/features/chat/provider-capabilities.ts diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index f7ba8afd94..1714c3ccbd 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -26,6 +26,7 @@ import { loadExternalProviders, parseExternalModelId, } from "../external-providers"; +import { getProviderCapabilities } from "../provider-capabilities"; import { useChatRuntimeStore } from "../stores/chat-runtime-store"; import { isMultimodalResponse } from "../types/api"; import type { ChatModelSummary } from "../types/runtime"; @@ -847,6 +848,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { externalProvider?.providerType === "custom" ? "openai" : externalProvider?.providerType; + const externalCapabilities = getProviderCapabilities( + externalProvider?.providerType, + ); const buildRequestPayload = async (forceRefreshPublicKey = false) => { if (externalSelection && externalProvider) { return { @@ -856,7 +860,14 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { temperature: params.temperature, top_p: params.topP, max_tokens: params.maxTokens, - presence_penalty: params.presencePenalty, + // Only forward sampling knobs the provider actually accepts; the + // backend's external-provider proxy is param-permissive and would + // surface a 400 from providers that reject unknown fields (e.g. + // OpenAI rejects top_k, Anthropic/DeepSeek reject presence_penalty). + ...(externalCapabilities?.topK ? { top_k: params.topK } : {}), + ...(externalCapabilities?.presencePenalty + ? { presence_penalty: params.presencePenalty } + : {}), provider_id: externalProvider.id, provider_type: externalBackendProviderType, external_model: externalSelection.modelId, diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 6fba8f9207..aeecf85e1a 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -42,7 +42,12 @@ import { ChatSettingsPanel } from "./chat-settings-sheet"; import { ContextUsageBar } from "./components/context-usage-bar"; import { ModelLoadInlineStatus } from "./components/model-load-status"; import { db } from "./db"; -import { buildExternalModelId, isExternalModelId } from "./external-providers"; +import { + buildExternalModelId, + isExternalModelId, + parseExternalModelId, +} from "./external-providers"; +import { getProviderCapabilities } from "./provider-capabilities"; import { useChatModelRuntime } from "./hooks/use-chat-model-runtime"; import { clearTrainingCompareHandoff, @@ -616,6 +621,14 @@ export function ChatPage(): ReactElement { () => isExternalModelId(inferenceParams.checkpoint), [inferenceParams.checkpoint], ); + const activeProviderCapabilities = useMemo(() => { + const selection = parseExternalModelId(inferenceParams.checkpoint); + if (!selection) return null; + const provider = externalProviders.find( + (p) => p.id === selection.providerId, + ); + return getProviderCapabilities(provider?.providerType); + }, [externalProviders, inferenceParams.checkpoint]); const canCompare = useMemo(() => { return Boolean(inferenceParams.checkpoint) && !isExternalModel; }, [inferenceParams.checkpoint, isExternalModel]); @@ -1188,6 +1201,7 @@ export function ChatPage(): ReactElement { params={inferenceParams} onParamsChange={setInferenceParams} isExternalModel={isExternalModel} + providerCapabilities={activeProviderCapabilities} onReloadModel={() => { const state = useChatRuntimeStore.getState(); if (state.params.checkpoint) { diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index ef46c970d3..e9a73891a4 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -80,6 +80,7 @@ import { toPresetParams, type Preset, } from "./presets/preset-policy"; +import type { ProviderCapabilities } from "./provider-capabilities"; import type { InferenceParams } from "./types/runtime"; export { defaultInferenceParams, type Preset } from "./presets/preset-policy"; @@ -506,6 +507,12 @@ interface ChatSettingsPanelProps { params: InferenceParams; onParamsChange: (params: InferenceParams) => void; isExternalModel?: boolean; + /** + * Sampling-param capability set for the active external provider, or `null` + * for local models (in which case every knob is rendered). Drives the + * per-param visibility in the sampling section. + */ + providerCapabilities?: ProviderCapabilities | null; onReloadModel?: () => void; } @@ -515,8 +522,19 @@ export function ChatSettingsPanel({ params, onParamsChange, isExternalModel = false, + providerCapabilities = null, onReloadModel, }: ChatSettingsPanelProps) { + // For non-external (local) models we show every knob — providerCapabilities + // is only consulted when `isExternalModel` is true. An external model with an + // unknown provider falls back to the OpenAI-compat shape via + // getProviderCapabilities, so these flags never undercount support. + const showTopK = !isExternalModel || Boolean(providerCapabilities?.topK); + const showMinP = !isExternalModel || Boolean(providerCapabilities?.minP); + const showRepetitionPenalty = + !isExternalModel || Boolean(providerCapabilities?.repetitionPenalty); + const showPresencePenalty = + !isExternalModel || Boolean(providerCapabilities?.presencePenalty); const isMobile = useIsMobile(); const isGguf = useChatRuntimeStore((s) => s.activeGgufVariant) != null; const hasModelContent = @@ -1153,51 +1171,55 @@ export function ChatSettingsPanel({ displayValue={params.topP === 1 ? "Off" : undefined} info="Nucleus sampling. Restricts choices to the smallest set of tokens whose cumulative probability reaches this threshold. 1.0 = off." /> - {!isExternalModel ? ( - <> - - - - + {showTopK ? ( + + ) : null} + {showMinP ? ( + + ) : null} + {showRepetitionPenalty ? ( + + ) : null} + {showPresencePenalty ? ( + ) : null} - {!isExternalModel && !isGguf && ( = { + openai: OPENAI_COMPAT_BASE, + // Anthropic's Messages API accepts top_k but not presence/frequency penalty. + anthropic: { + topK: true, + minP: false, + repetitionPenalty: false, + presencePenalty: false, + }, + mistral: OPENAI_COMPAT_BASE, + gemini: OPENAI_COMPAT_BASE, + // DeepSeek deprecated presence/frequency penalty in their current docs. + deepseek: { + topK: false, + minP: false, + repetitionPenalty: false, + presencePenalty: false, + }, + kimi: OPENAI_COMPAT_BASE, + qwen: OPENAI_COMPAT_BASE, + huggingface: OPENAI_COMPAT_BASE, + // OpenRouter silently drops params the target model does not support, so we + // surface every knob and let the gateway handle the per-model fan-out. + openrouter: ALL_SUPPORTED, + // Custom providers are assumed OpenAI-compatible by the backend; users who + // point at vLLM/Ollama backends often want top_k / min_p / repetition, + // so be permissive. + custom: ALL_SUPPORTED, +}; + +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". + */ +export function getProviderCapabilities( + providerType: string | null | undefined, +): ProviderCapabilities | null { + if (!providerType) return null; + return PROVIDER_CAPABILITIES[providerType] ?? DEFAULT_EXTERNAL_CAPABILITIES; +}