From 9923f4e3a5652cb3e881385526f130ff5fe1fce4 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:55:48 +0000 Subject: [PATCH 1/2] Publish and validate per-model thinking levels Every model's reasoning controls now come from one table in model_config.rs, verified live against both providers on 2026-10-07 (issue #1087). It is published as an OpenRouter-shaped `reasoning` object (mandatory, default_enabled, supported_efforts, default_effort) plus `max_completion_tokens` on `/v1/models` and the catalog, with aliases publishing the intersection of their candidates. Chat Completions validates `reasoning_effort` after alias and route resolution: an explicit model rejects an unsupported value with OpenAI's `unsupported_value` error (type, param and the accepted list in the message) before any provider is called; `auto:` aliases move the value to the nearest effort the resolved model accepts. Thinking-off template switches are dropped for models whose reasoning is mandatory, because the deployed GLM builds leak reasoning into `content` on every off control. Kimi K3 receives `developer` messages as `system`. Responses accepts `reasoning: { effort }`, validates it before any write and forwards it to the model turn. The Rust and TypeScript SDKs gain the catalog types, `ChatMessage.reasoning` and `reasoning_effort`, and the Research composer gets a thinking-level control built from the selected model's catalog entry. Existing requests, responses and error bodies are unchanged. Co-Authored-By: Claude Fable 5.1 --- .../frontend/src/components/UnifiedChat.tsx | 52 +- .../components/chat/ThinkingLevelSelector.tsx | 81 +++ .../src/services/chatComposerQueue.ts | 5 +- .../src/services/chatThinkingLevel.test.ts | 133 ++++ .../src/services/chatThinkingLevel.ts | 156 +++++ .../src/state/LocalStateContextDef.ts | 20 + proxy/README.md | 61 ++ sdk/rust/src/client.rs | 2 + sdk/rust/src/types.rs | 77 +++ sdk/rust/tests/ai_integration.rs | 11 + sdk/rust/tests/api_keys.rs | 2 + sdk/src/lib/api.ts | 87 ++- sdk/src/lib/index.ts | 4 + sdk/src/lib/main.tsx | 2 +- .../opensecret/docs/development-contracts.md | 9 + services/opensecret/src/main.rs | 40 ++ services/opensecret/src/model_config.rs | 622 +++++++++++++++++- services/opensecret/src/web/mod.rs | 1 + .../src/web/model_request_policy.rs | 502 ++++++++++++++ services/opensecret/src/web/openai.rs | 43 +- .../opensecret/src/web/responses/handlers.rs | 213 +++++- 21 files changed, 2057 insertions(+), 66 deletions(-) create mode 100644 apps/maple-research/frontend/src/components/chat/ThinkingLevelSelector.tsx create mode 100644 apps/maple-research/frontend/src/services/chatThinkingLevel.test.ts create mode 100644 apps/maple-research/frontend/src/services/chatThinkingLevel.ts create mode 100644 services/opensecret/src/web/model_request_policy.rs diff --git a/apps/maple-research/frontend/src/components/UnifiedChat.tsx b/apps/maple-research/frontend/src/components/UnifiedChat.tsx index fe1076aaf..401361b73 100644 --- a/apps/maple-research/frontend/src/components/UnifiedChat.tsx +++ b/apps/maple-research/frontend/src/components/UnifiedChat.tsx @@ -73,6 +73,14 @@ import { continueChatComposerListBeforeInput } from "@/components/chatComposerListContinuation"; import { ComposerHoverTooltip } from "@/components/chat/ComposerHoverTooltip"; +import { ThinkingLevelSelector } from "@/components/chat/ThinkingLevelSelector"; +import { + getStoredThinkingLevel, + resolveModelReasoning, + resolveThinkingLevel, + storeThinkingLevel, + type ThinkingLevelChoice +} from "@/services/chatThinkingLevel"; import { ModelSelector } from "@/components/ModelSelector"; import { useBillingState, useModelState, useSelectedProjectState } from "@/state/useLocalState"; import { isKnownFreePlan } from "@/billing/billingAccess"; @@ -1535,7 +1543,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { const isLandscapeMobile = useIsLandscapeMobile(); const isCompactLayout = isMobile || isLandscapeMobile; const openai = useOpenAI(); - const { model, hasWhisperModel } = useModelState(); + const { model, hasWhisperModel, availableModels, modelAliases } = useModelState(); const { billingStatus } = useBillingState(); const { selectedProjectId, setSelectedProjectId } = useSelectedProjectState(); const os = useOpenSecret(); @@ -1829,6 +1837,23 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { // Web search toggle state - persisted in localStorage, billing-aware initial default const [isWebSearchEnabled, setIsWebSearchEnabled] = useState(getInitialWebSearchEnabled); + // Thinking level - persisted in localStorage; only levels the selected + // model's catalog entry publishes are ever sent. + const [thinkingLevel, setThinkingLevel] = useState(getStoredThinkingLevel); + const selectedModelReasoning = resolveModelReasoning( + model || DEFAULT_MODEL_ID, + availableModels, + modelAliases + ); + const effectiveReasoningEffort = resolveThinkingLevel( + thinkingLevel, + selectedModelReasoning + ).effort; + const handleThinkingLevelChange = useCallback((choice: ThinkingLevelChoice) => { + setThinkingLevel(choice); + storeThinkingLevel(choice); + }, []); + // Fullscreen mode for power users - persisted in localStorage const [isFullscreen, setIsFullscreen] = useState(() => { return localStorage.getItem("chatFullscreen") === "true"; @@ -4983,6 +5008,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { messageId: uuidv4(), model: model || DEFAULT_MODEL_ID, webSearchEnabled: isWebSearchEnabled, + reasoningEffort: effectiveReasoningEffort, createdMs: Date.now() }; const composerAtSubmit = chatComposerWithInputOverride(startSnapshot.composer, overrideInput); @@ -5212,7 +5238,14 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { metadata: { internal_message_id: localMessageId }, stream: true, store: true, - ...(item.webSearchEnabled && { tools: [{ type: "web_search" }] }) + ...(item.webSearchEnabled && { tools: [{ type: "web_search" }] }), + // The catalog decides which efforts a model accepts; openai 5.x's + // ReasoningEffort type predates `none`, `xhigh` and `max`. + ...(item.reasoningEffort && { + reasoning: { effort: item.reasoningEffort } as NonNullable< + ResponseCreateParamsStreaming["reasoning"] + > + }) }; const stream = await withInferenceCapacityRetry( (maxInferenceSends) => { @@ -5602,6 +5635,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) { isRuntimeSelected, isCompactLayout, isWebSearchEnabled, + effectiveReasoningEffort, model, openai, processStreamingResponse, @@ -5916,6 +5950,13 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
+ + + + diff --git a/apps/maple-research/frontend/src/components/chat/ThinkingLevelSelector.tsx b/apps/maple-research/frontend/src/components/chat/ThinkingLevelSelector.tsx new file mode 100644 index 000000000..c00bc26a3 --- /dev/null +++ b/apps/maple-research/frontend/src/components/chat/ThinkingLevelSelector.tsx @@ -0,0 +1,81 @@ +import { BrainCog, Check } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@/components/ui/dropdown-menu"; +import { ComposerHoverTooltip } from "@/components/chat/ComposerHoverTooltip"; +import type { ModelReasoning } from "@/state/LocalStateContextDef"; +import { + resolveThinkingLevel, + thinkingLevelLabel, + type ThinkingLevelChoice +} from "@/services/chatThinkingLevel"; + +export type ThinkingLevelSelectorProps = { + /** The selected model's catalog reasoning controls; hides the control when absent. */ + reasoning: ModelReasoning | null | undefined; + value: ThinkingLevelChoice; + onChange: (choice: ThinkingLevelChoice) => void; + disabled?: boolean; +}; + +/** + * Composer control for the thinking level sent with each message. The options + * come from the catalog, so a model only ever receives a level it accepts. + */ +export function ThinkingLevelSelector({ + reasoning, + value, + onChange, + disabled +}: ThinkingLevelSelectorProps) { + const { options, effective } = resolveThinkingLevel(value, reasoning); + if (options.length === 0) return null; + + const currentLabel = thinkingLevelLabel(effective); + const isDefault = effective === "default"; + + return ( + + + + + + + + {options.map((option) => ( + onChange(option.value)} + className="flex items-center justify-between gap-2" + data-testid={`thinking-level-option-${option.value}`} + > + + {option.label} + {option.description && ( + {option.description} + )} + + {option.value === effective && } + + ))} + + + ); +} diff --git a/apps/maple-research/frontend/src/services/chatComposerQueue.ts b/apps/maple-research/frontend/src/services/chatComposerQueue.ts index ac70e9bd6..d2aad633f 100644 --- a/apps/maple-research/frontend/src/services/chatComposerQueue.ts +++ b/apps/maple-research/frontend/src/services/chatComposerQueue.ts @@ -4,6 +4,7 @@ import { queuedMessageEditStillPresent as sharedQueuedMessageEditStillPresent, type QueuedMessageEdit } from "./composerQueue"; +import type { ReasoningEffort } from "@/state/LocalStateContextDef"; export const MAX_CHAT_QUEUED_MESSAGES = 16; export const MAX_CHAT_QUEUED_TEXT_BYTES = 32 * 1024; @@ -27,6 +28,8 @@ export type ChatQueuedMessage = { draftProjectId: string | null; model: string; webSearchEnabled: boolean; + /** The thinking level chosen at submit time, if the model offered one. */ + reasoningEffort?: ReasoningEffort; createdMs: number; }; @@ -39,7 +42,7 @@ export type ChatComposerQueueState = { export type ChatQueuedMessageMetadata = Pick< ChatQueuedMessage, - "queueId" | "messageId" | "model" | "webSearchEnabled" | "createdMs" + "queueId" | "messageId" | "model" | "webSearchEnabled" | "reasoningEffort" | "createdMs" >; export type ChatComposerDraft = { diff --git a/apps/maple-research/frontend/src/services/chatThinkingLevel.test.ts b/apps/maple-research/frontend/src/services/chatThinkingLevel.test.ts new file mode 100644 index 000000000..884a2d3b2 --- /dev/null +++ b/apps/maple-research/frontend/src/services/chatThinkingLevel.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "bun:test"; +import type { + ModelReasoning, + OpenSecretModel, + OpenSecretModelAlias +} from "@/state/LocalStateContextDef"; +import { + getStoredThinkingLevel, + resolveModelReasoning, + resolveThinkingLevel, + storeThinkingLevel, + thinkingLevelLabel, + thinkingLevelOptions +} from "./chatThinkingLevel"; + +const GLM: ModelReasoning = { + mandatory: true, + default_enabled: true, + supported_efforts: ["max", "high", "low"], + default_effort: "max" +}; +const KIMI: ModelReasoning = { + mandatory: false, + default_enabled: true, + supported_efforts: ["max", "high", "low"], + default_effort: "max" +}; +const GPT_OSS: ModelReasoning = { + mandatory: true, + default_enabled: true, + supported_efforts: ["high", "medium", "low"], + default_effort: "medium" +}; +const GEMMA: ModelReasoning = { mandatory: false, default_enabled: false }; + +function values(reasoning: ModelReasoning | null | undefined): string[] { + return thinkingLevelOptions(reasoning).map((option) => option.value); +} + +describe("thinkingLevelOptions", () => { + test("hides the control for models that never reason", () => { + expect(values(undefined)).toEqual([]); + expect(values(null)).toEqual([]); + }); + + test("lists default, then off where allowed, then efforts from low to high", () => { + expect(values(GLM)).toEqual(["default", "low", "high", "max"]); + expect(values(KIMI)).toEqual(["default", "none", "low", "high", "max"]); + expect(values(GPT_OSS)).toEqual(["default", "low", "medium", "high"]); + expect(values(GEMMA)).toEqual(["default", "none"]); + }); + + test("describes the default with the model's own default effort", () => { + expect(thinkingLevelOptions(GLM)[0].description).toBe("Model default (max)"); + expect(thinkingLevelOptions(GEMMA)[0].description).toBeUndefined(); + expect(thinkingLevelLabel("xhigh")).toBe("Extra high"); + expect(thinkingLevelLabel("none")).toBe("Off"); + expect(thinkingLevelLabel("default")).toBe("Default"); + }); +}); + +describe("resolveThinkingLevel", () => { + test("sends only levels the selected model publishes", () => { + expect(resolveThinkingLevel("low", GLM).effort).toBe("low"); + expect(resolveThinkingLevel("default", GLM).effort).toBeUndefined(); + // A stored medium came from another model; GLM falls back to its default. + const medium = resolveThinkingLevel("medium", GLM); + expect(medium.effective).toBe("default"); + expect(medium.effort).toBeUndefined(); + // Off is only offered where reasoning is not mandatory. + expect(resolveThinkingLevel("none", GLM).effort).toBeUndefined(); + expect(resolveThinkingLevel("none", KIMI).effort).toBe("none"); + expect(resolveThinkingLevel("none", GEMMA).effort).toBe("none"); + expect(resolveThinkingLevel("high", GEMMA).effective).toBe("default"); + }); + + test("never sends an effort for a model without reasoning", () => { + const resolved = resolveThinkingLevel("max", null); + expect(resolved.options).toEqual([]); + expect(resolved.effective).toBe("default"); + expect(resolved.effort).toBeUndefined(); + }); +}); + +describe("resolveModelReasoning", () => { + const models: OpenSecretModel[] = [ + { id: "glm-5-3", object: "model", created: 0, owned_by: "opensecret", reasoning: GLM }, + { id: "llama3-3-70b", object: "model", created: 0, owned_by: "opensecret" }, + { id: "old-server", object: "model", created: 0, owned_by: "opensecret", reasoning: null } + ]; + const aliases: OpenSecretModelAlias[] = [ + { + id: "auto:quick", + label: "Quick", + short_name: "Quick", + description: "", + target_model: "glm-5-3-flash", + reasoning: GLM + } + ]; + + test("prefers the alias entry, then the model entry", () => { + expect(resolveModelReasoning("auto:quick", models, aliases)).toEqual(GLM); + expect(resolveModelReasoning("glm-5-3", models, aliases)).toEqual(GLM); + }); + + test("is undefined for unknown or metadata-less models and null when published as null", () => { + expect(resolveModelReasoning("llama3-3-70b", models, aliases)).toBeUndefined(); + expect(resolveModelReasoning("missing", models, aliases)).toBeUndefined(); + expect(resolveModelReasoning("old-server", models, aliases)).toBeNull(); + }); +}); + +describe("stored thinking level", () => { + function memoryStorage(initial: Record = {}) { + const store = new Map(Object.entries(initial)); + return { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value); + } + }; + } + + test("round-trips valid choices and ignores anything else", () => { + const storage = memoryStorage(); + expect(getStoredThinkingLevel(storage)).toBe("default"); + storeThinkingLevel("high", storage); + expect(getStoredThinkingLevel(storage)).toBe("high"); + expect(getStoredThinkingLevel(memoryStorage({ chatThinkingLevel: "ultra" }))).toBe("default"); + expect(getStoredThinkingLevel(null)).toBe("default"); + }); +}); diff --git a/apps/maple-research/frontend/src/services/chatThinkingLevel.ts b/apps/maple-research/frontend/src/services/chatThinkingLevel.ts new file mode 100644 index 000000000..4d95128f2 --- /dev/null +++ b/apps/maple-research/frontend/src/services/chatThinkingLevel.ts @@ -0,0 +1,156 @@ +import type { + ModelReasoning, + OpenSecretModel, + OpenSecretModelAlias, + ReasoningEffort +} from "@/state/LocalStateContextDef"; + +/** + * The composer's thinking choice: let the model decide, or one effort the + * selected model's catalog entry accepts. + */ +export type ThinkingLevelChoice = "default" | ReasoningEffort; + +export type ThinkingLevelOption = { + value: ThinkingLevelChoice; + label: string; + description?: string; +}; + +export const THINKING_LEVEL_STORAGE_KEY = "chatThinkingLevel"; + +const EFFORT_ORDER: ReasoningEffort[] = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" +]; + +const EFFORT_LABELS: Record = { + none: "Off", + minimal: "Minimal", + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra high", + max: "Max" +}; + +export function isReasoningEffort(value: unknown): value is ReasoningEffort { + return typeof value === "string" && (EFFORT_ORDER as string[]).includes(value); +} + +export function isThinkingLevelChoice(value: unknown): value is ThinkingLevelChoice { + return value === "default" || isReasoningEffort(value); +} + +export function thinkingLevelLabel(choice: ThinkingLevelChoice): string { + return choice === "default" ? "Default" : EFFORT_LABELS[choice]; +} + +/** + * The reasoning controls published for the selected model or alias, or + * `undefined` while the catalog has not loaded or the model is unknown. + */ +export function resolveModelReasoning( + modelId: string, + models: readonly OpenSecretModel[], + aliases: readonly OpenSecretModelAlias[] +): ModelReasoning | null | undefined { + const alias = aliases.find((candidate) => candidate.id === modelId); + if (alias) return alias.reasoning ?? (alias.reasoning === null ? null : undefined); + const model = models.find((candidate) => candidate.id === modelId); + if (!model) return undefined; + return model.reasoning ?? (model.reasoning === null ? null : undefined); +} + +/** + * The choices the composer offers for a model: the model's default, `Off` + * when reasoning is not mandatory, then every supported effort from lowest to + * highest. Empty when the model never reasons, so the control stays hidden. + */ +export function thinkingLevelOptions( + reasoning: ModelReasoning | null | undefined +): ThinkingLevelOption[] { + if (!reasoning) return []; + const options: ThinkingLevelOption[] = [ + { + value: "default", + label: "Default", + description: reasoning.default_effort + ? `Model default (${EFFORT_LABELS[reasoning.default_effort].toLowerCase()})` + : undefined + } + ]; + if (!reasoning.mandatory) { + options.push({ value: "none", label: EFFORT_LABELS.none }); + } + const supported = reasoning.supported_efforts ?? []; + for (const effort of EFFORT_ORDER) { + if (effort !== "none" && supported.includes(effort)) { + options.push({ value: effort, label: EFFORT_LABELS[effort] }); + } + } + return options; +} + +export type ResolvedThinkingLevel = { + options: ThinkingLevelOption[]; + /** The stored choice when the model offers it, otherwise `default`. */ + effective: ThinkingLevelChoice; + /** The `reasoning.effort` to send, or `undefined` to let the model decide. */ + effort: ReasoningEffort | undefined; +}; + +/** + * Resolve a stored choice against the selected model. A choice the model does + * not offer falls back to the model's default, so switching models never + * sends a level the catalog did not publish. + */ +export function resolveThinkingLevel( + choice: ThinkingLevelChoice, + reasoning: ModelReasoning | null | undefined +): ResolvedThinkingLevel { + const options = thinkingLevelOptions(reasoning); + const effective = options.some((option) => option.value === choice) ? choice : "default"; + return { + options, + effective, + effort: effective === "default" ? undefined : effective + }; +} + +type ThinkingLevelStorage = Pick; + +function browserStorage(): ThinkingLevelStorage | null { + try { + return typeof localStorage === "undefined" ? null : localStorage; + } catch { + return null; + } +} + +export function getStoredThinkingLevel( + storage: ThinkingLevelStorage | null = browserStorage() +): ThinkingLevelChoice { + try { + const stored = storage?.getItem(THINKING_LEVEL_STORAGE_KEY); + return isThinkingLevelChoice(stored) ? stored : "default"; + } catch { + return "default"; + } +} + +export function storeThinkingLevel( + choice: ThinkingLevelChoice, + storage: ThinkingLevelStorage | null = browserStorage() +): void { + try { + storage?.setItem(THINKING_LEVEL_STORAGE_KEY, choice); + } catch { + // Storage is a convenience; the in-memory choice still applies. + } +} diff --git a/apps/maple-research/frontend/src/state/LocalStateContextDef.ts b/apps/maple-research/frontend/src/state/LocalStateContextDef.ts index 0cf8fbd77..2d5bfaeef 100644 --- a/apps/maple-research/frontend/src/state/LocalStateContextDef.ts +++ b/apps/maple-research/frontend/src/state/LocalStateContextDef.ts @@ -12,6 +12,21 @@ export type ModelCapabilities = { tool_use?: boolean; }; +/** OpenAI's `reasoning_effort` vocabulary; each model accepts a subset. */ +export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; + +/** + * The reasoning controls a model accepts, as the catalog publishes them. + * `supported_efforts` is highest first; a model without it has no tiers and + * only switches thinking off with `none`. `none` is rejected when `mandatory`. + */ +export type ModelReasoning = { + mandatory: boolean; + default_enabled?: boolean; + supported_efforts?: ReasoningEffort[]; + default_effort?: ReasoningEffort; +}; + export interface OpenSecretModel extends Model { tasks?: string[]; provider?: string; @@ -21,8 +36,10 @@ export interface OpenSecretModel extends Model { description?: string; context_window?: number; max_context_tokens?: number; + max_completion_tokens?: number; access?: ModelAccessTier; capabilities?: ModelCapabilities; + reasoning?: ModelReasoning | null; badges?: string[]; enabled?: boolean; deprecated?: boolean; @@ -37,6 +54,9 @@ export type OpenSecretModelAlias = { target_model: string; access?: ModelAccessTier; capabilities?: ModelCapabilities; + context_window?: number; + max_completion_tokens?: number; + reasoning?: ModelReasoning | null; }; export type OpenSecretModelCatalog = { diff --git a/proxy/README.md b/proxy/README.md index 37c65fd09..183209399 100644 --- a/proxy/README.md +++ b/proxy/README.md @@ -165,6 +165,67 @@ Set `stream` to `true` for Server-Sent Events or `false` for one JSON response. Additional provider-specific JSON fields are forwarded without being parsed or rewritten by the proxy or Rust SDK. +#### Reasoning effort (thinking levels) + +Reasoning models accept OpenAI's top-level `reasoning_effort` field with the +standard vocabulary `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. +Each model accepts a subset, published on its `/v1/models` entry as a +`reasoning` object (the same shape OpenRouter publishes): + +```json +{ + "id": "glm-5-3", + "max_completion_tokens": 262144, + "reasoning": { + "mandatory": true, + "default_enabled": true, + "supported_efforts": ["max", "high", "low"], + "default_effort": "max" + } +} +``` + +- `supported_efforts` lists the accepted values, highest first. Omit the field + to get `default_effort`. +- `none` turns reasoning off and is only accepted when `mandatory` is `false`. +- A model without `supported_efforts` has no tiers: any effort other than + `none` turns reasoning on. +- Models that never reason have no `reasoning` object and ignore the field. +- `max_completion_tokens` is the most a request can receive; input and output + share the context window, so the effective limit is that less the prompt. + Reasoning tokens count against `max_completion_tokens` (and `max_tokens`). + +An unsupported value on an explicit model is rejected before any provider is +called, in OpenAI's shape: + +```json +{ + "status": 400, + "message": "Unsupported value: 'medium' is not supported with the 'glm-5-3' model. Supported values are: 'low', 'high', 'max'.", + "error": { + "message": "Unsupported value: 'medium' is not supported with the 'glm-5-3' model. Supported values are: 'low', 'high', 'max'.", + "code": "unsupported_value", + "type": "invalid_request_error", + "param": "reasoning_effort" + } +} +``` + +On the `auto:quick` and `auto:powerful` aliases the value is instead moved to +the nearest effort the resolved model accepts (the next higher one, else the +next lower one), so a fallback to another model never fails a running task. +Alias entries in the catalog publish only the efforts every model they can +resolve to accepts. + +Reasoning text comes back in `message.reasoning` (non-streaming) and +`delta.reasoning` (streaming); one provider route also sends a deprecated +duplicate `reasoning_content`, so read `reasoning` first and never concatenate +the two. `usage.completion_tokens_details.reasoning_tokens` is passed through +where the provider reports it. Template switches such as +`chat_template_kwargs.enable_thinking` are not part of the contract: switches +that would turn thinking off are dropped for models whose reasoning is +mandatory. + #### Embeddings ```bash curl http://localhost:8080/v1/embeddings \ diff --git a/sdk/rust/src/client.rs b/sdk/rust/src/client.rs index ec1a2ae8c..6fe25e7f3 100644 --- a/sdk/rust/src/client.rs +++ b/sdk/rust/src/client.rs @@ -3274,8 +3274,10 @@ mod tests { #[test] fn typed_stream_request_keeps_sse_and_json_headers_and_v2_stream_flags() { let request = ChatCompletionRequest { + reasoning_effort: None, model: "test-model".to_string(), messages: vec![ChatMessage { + reasoning: None, role: "user".to_string(), content: serde_json::json!("hello"), tool_calls: None, diff --git a/sdk/rust/src/types.rs b/sdk/rust/src/types.rs index cd68c0169..a57570377 100644 --- a/sdk/rust/src/types.rs +++ b/sdk/rust/src/types.rs @@ -683,6 +683,49 @@ pub struct Model { pub created: Option, #[serde(skip_serializing_if = "Option::is_none")] pub owned_by: Option, + /// Most output tokens a request can receive. Input and output share the + /// context window, so the effective limit is this less the prompt. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_completion_tokens: Option, + /// How the model exposes reasoning effort; absent for models that never + /// reason. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning: Option, +} + +/// The reasoning controls a model accepts, as published in the catalog +/// `reasoning` object (the same shape OpenRouter publishes). +/// +/// Send one of `supported_efforts` as `reasoning_effort` (Chat Completions) +/// or `reasoning.effort` (Responses). `none` turns reasoning off and is only +/// accepted when `mandatory` is false. A model without `supported_efforts` +/// has no tiers: any effort other than `none` turns reasoning on. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModelReasoning { + /// Reasoning cannot be turned off; `none` is rejected. + #[serde(default)] + pub mandatory: bool, + /// The model reasons when a request names no effort. + #[serde(default)] + pub default_enabled: bool, + /// Accepted efforts, highest first. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub supported_efforts: Option>, + /// The effort used when a request names none. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_effort: Option, +} + +impl ModelReasoning { + /// Whether a request may name this effort for the model. + pub fn accepts(&self, effort: &str) -> bool { + if effort == "none" { + return !self.mandatory; + } + self.supported_efforts + .as_ref() + .is_none_or(|efforts| efforts.iter().any(|accepted| accepted == effort)) + } } /// A catalog entry: model identity plus context and capability metadata. @@ -699,6 +742,13 @@ pub struct CatalogModel { pub max_context_tokens: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub capabilities: Option, + /// Most output tokens a request can receive; see [`Model::max_completion_tokens`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_completion_tokens: Option, + /// How the model exposes reasoning effort; absent for models that never + /// reason. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning: Option, } /// Capability flags a catalog entry advertises. @@ -724,6 +774,13 @@ pub struct CatalogAlias { pub target_model: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub capabilities: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_window: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_completion_tokens: Option, + /// The reasoning controls every model the alias may resolve to accepts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning: Option, } /// Default alias assignments from the catalog. @@ -795,14 +852,34 @@ pub struct ChatMessage { pub content: Value, // Now accepts both string and array formats #[serde(skip_serializing_if = "Option::is_none")] pub tool_calls: Option>, + /// The model's reasoning. Every current route returns it in this field; + /// replaying it on an assistant message is accepted by every model. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning: Option, + /// Deprecated duplicate of `reasoning` that one provider route still + /// sends. Read `reasoning` first and never concatenate the two. #[serde(skip_serializing_if = "Option::is_none")] pub reasoning_content: Option, } +impl ChatMessage { + /// The reasoning text, from whichever field the route populated. + pub fn reasoning_text(&self) -> Option<&str> { + self.reasoning + .as_deref() + .or(self.reasoning_content.as_deref()) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChatCompletionRequest { pub model: String, pub messages: Vec, + /// One of the model's catalog `reasoning.supported_efforts`, or `none` + /// where reasoning is not mandatory. An unsupported value on an explicit + /// model is rejected with OpenAI's `unsupported_value` error. + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, #[serde(skip_serializing_if = "Option::is_none")] pub temperature: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/sdk/rust/tests/ai_integration.rs b/sdk/rust/tests/ai_integration.rs index cf4aee68f..620240fe3 100644 --- a/sdk/rust/tests/ai_integration.rs +++ b/sdk/rust/tests/ai_integration.rs @@ -198,8 +198,10 @@ async fn test_chat_completion_streaming() { .expect("Failed to setup client"); let request = ChatCompletionRequest { + reasoning_effort: None, model: chat_model(), messages: vec![ChatMessage { + reasoning: None, role: "user".to_string(), content: serde_json::json!(r#"please reply with exactly and only the word "echo""#), tool_calls: None, @@ -272,8 +274,10 @@ async fn test_reasoning_content_with_kimi_k2() { .expect("Failed to setup client"); let request = ChatCompletionRequest { + reasoning_effort: None, model: reasoning_model(), messages: vec![ChatMessage { + reasoning: None, role: "user".to_string(), content: serde_json::json!("What is 2+2?"), tool_calls: None, @@ -326,9 +330,11 @@ async fn test_chat_completion_with_system_message() { .expect("Failed to setup client"); let request = ChatCompletionRequest { + reasoning_effort: None, model: chat_model(), messages: vec![ ChatMessage { + reasoning: None, role: "system".to_string(), content: serde_json::json!( "You are a helpful assistant that always responds with exactly one word." @@ -337,6 +343,7 @@ async fn test_chat_completion_with_system_message() { reasoning_content: None, }, ChatMessage { + reasoning: None, role: "user".to_string(), content: serde_json::json!("What is 2+2? Answer in one word."), tool_calls: None, @@ -448,8 +455,10 @@ async fn test_guest_user_cannot_use_ai() { // Try to create completion - should fail let request = ChatCompletionRequest { + reasoning_effort: None, model: "some-model".to_string(), messages: vec![ChatMessage { + reasoning: None, role: "user".to_string(), content: serde_json::json!("test"), tool_calls: None, @@ -691,8 +700,10 @@ async fn test_streaming_multi_tool_calls() { ]; let request = ChatCompletionRequest { + reasoning_effort: None, model: chat_model(), messages: vec![ChatMessage { + reasoning: None, role: "user".to_string(), content: serde_json::json!("What is the weather in NYC and what time is it there?"), tool_calls: None, diff --git a/sdk/rust/tests/api_keys.rs b/sdk/rust/tests/api_keys.rs index 6c33f0773..2fa3d7937 100644 --- a/sdk/rust/tests/api_keys.rs +++ b/sdk/rust/tests/api_keys.rs @@ -155,8 +155,10 @@ async fn test_streaming_chat_with_api_key() -> Result<()> { // Test streaming chat completion let request = ChatCompletionRequest { + reasoning_effort: None, model: chat_model(), messages: vec![ChatMessage { + reasoning: None, role: "user".to_string(), content: serde_json::json!("Please reply with exactly and only the word 'echo'"), tool_calls: None, diff --git a/sdk/src/lib/api.ts b/sdk/src/lib/api.ts index e12644bee..7d3687be2 100644 --- a/sdk/src/lib/api.ts +++ b/sdk/src/lib/api.ts @@ -1261,7 +1261,7 @@ export async function confirmAccountDeletion( type ModelsListResponse = { object: "list"; - data: Model[]; + data: ModelListItem[]; }; export type ModelAliasId = "auto:quick" | "auto:powerful"; @@ -1279,24 +1279,67 @@ export type ModelCapabilities = { tool_use: boolean; }; -export type ModelCatalogItem = Model & { - provider?: string; - provider_id?: string; - display_name: string; - short_name: string; - description?: string; - context_window: number; - max_context_tokens: number; - access: ModelAccessTier; - capabilities: ModelCapabilities; - tasks?: string[]; - badges?: string[]; - enabled: boolean; - deprecated: boolean; - sort_order?: number; -}; +/** + * OpenAI's `reasoning_effort` vocabulary. Each model accepts a subset, + * published as `ModelReasoning.supported_efforts`. + */ +export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; -export type ModelAlias = { +/** + * The reasoning controls a model accepts, published as the catalog `reasoning` + * object (the same shape OpenRouter publishes). + * + * Send one of `supported_efforts` as `reasoning_effort` (Chat Completions) or + * `reasoning.effort` (Responses). `none` turns reasoning off and is only + * accepted when `mandatory` is false. A model without `supported_efforts` has + * no tiers: any effort other than `none` turns reasoning on. An unsupported + * value on an explicit model is rejected with OpenAI's `unsupported_value` + * error; on an `auto:` alias it is moved to the nearest accepted effort. + */ +export type ModelReasoning = { + /** Reasoning cannot be turned off; `none` is rejected. */ + mandatory: boolean; + /** The model reasons when a request names no effort. */ + default_enabled?: boolean; + /** Accepted efforts, highest first. */ + supported_efforts?: ReasoningEffort[]; + /** The effort used when a request names none. */ + default_effort?: ReasoningEffort; +}; + +/** Catalog fields shared by `/v1/models` entries and the richer catalog. */ +export type ModelReasoningMetadata = { + /** + * Most output tokens a request can receive. Input and output share the + * context window, so the effective limit is this less the prompt. + */ + max_completion_tokens?: number; + /** How the model exposes reasoning effort; absent for models that never reason. */ + reasoning?: ModelReasoning; +}; + +/** An entry of `/v1/models`: OpenAI's model object plus Maple's metadata. */ +export type ModelListItem = Model & ModelReasoningMetadata; + +export type ModelCatalogItem = Model & + ModelReasoningMetadata & { + provider?: string; + provider_id?: string; + display_name: string; + short_name: string; + description?: string; + context_window: number; + max_context_tokens: number; + access: ModelAccessTier; + capabilities: ModelCapabilities; + tasks?: string[]; + badges?: string[]; + enabled: boolean; + deprecated: boolean; + sort_order?: number; + }; + +export type ModelAlias = ModelReasoningMetadata & { id: ModelAliasId; label: string; short_name: string; @@ -1304,6 +1347,7 @@ export type ModelAlias = { target_model: string; access: ModelAccessTier; capabilities: ModelCapabilities; + context_window?: number; }; export type ModelCatalogResponse = { @@ -1341,7 +1385,7 @@ export type ModelCatalogResponse = { * stay on the authenticated path and are never retried anonymously if * validation fails. */ -export async function fetchModels(apiKey?: string): Promise { +export async function fetchModels(apiKey?: string): Promise { try { const hasIdentityCredential = apiKey !== undefined || readTransportV2Credentials(apiUrl, "user") !== null; @@ -2176,6 +2220,11 @@ export type ResponsesCreateRequest = { previous_response_id?: string; // Deprecated but still supported stream?: boolean; metadata?: Record; + /** + * Reasoning controls, as on OpenAI's Responses API. `effort` must be one the + * model's catalog `reasoning` accepts; `summary` is accepted and ignored. + */ + reasoning?: { effort?: ReasoningEffort | null; summary?: string | null }; }; /** diff --git a/sdk/src/lib/index.ts b/sdk/src/lib/index.ts index 030f37599..76ca6890d 100644 --- a/sdk/src/lib/index.ts +++ b/sdk/src/lib/index.ts @@ -22,6 +22,10 @@ export type { ModelId, ModelAccessTier, ModelCapabilities, + ReasoningEffort, + ModelReasoning, + ModelReasoningMetadata, + ModelListItem, ModelCatalogItem, ModelAlias, ModelCatalogResponse, diff --git a/sdk/src/lib/main.tsx b/sdk/src/lib/main.tsx index c8a53a827..28edb082d 100644 --- a/sdk/src/lib/main.tsx +++ b/sdk/src/lib/main.tsx @@ -452,7 +452,7 @@ export type OpenSecretContextType = { * - Before sign-in, uses an attested encrypted session without identity authorization * - When a JWT or API key is present, preserves authenticated model filtering */ - fetchModels: () => Promise; + fetchModels: () => Promise; /** * Fetches OpenSecret's model catalog with display metadata and stable aliases. diff --git a/services/opensecret/docs/development-contracts.md b/services/opensecret/docs/development-contracts.md index 4ab888cb0..92b0b324d 100644 --- a/services/opensecret/docs/development-contracts.md +++ b/services/opensecret/docs/development-contracts.md @@ -66,6 +66,15 @@ transport; protected routes are not ordinary plaintext `fetch`, `curl`, or current SSRF policy. - Keep usage tied to the actual provider and canonical public model while preserving the established user or API-key attribution. +- Reasoning effort is one per-model table (`model_config::ModelReasoning`) that + feeds the catalog `reasoning` object, request validation and tests. Clients + choose a level only through OpenAI's `reasoning_effort` (Chat Completions) and + `reasoning.effort` (Responses); template switches are not a client contract. + Validate after alias resolution against the model that runs: reject an + unsupported value on an explicit model with OpenAI's `unsupported_value` + error, move it to the nearest accepted effort on an `auto:` alias, and never + forward a thinking-off control to a model whose reasoning is mandatory. + Re-verify the table live whenever a provider changes its engine build. ## Persistence and migrations diff --git a/services/opensecret/src/main.rs b/services/opensecret/src/main.rs index 91e307e08..7797d2130 100644 --- a/services/opensecret/src/main.rs +++ b/services/opensecret/src/main.rs @@ -394,6 +394,11 @@ pub enum ApiError { #[error("{0}")] SystemOneRequest(web::system_one::SystemOneRequestError), + /// A `reasoning_effort` the resolved model does not accept; OpenAI's + /// `unsupported_value` rejection. + #[error("{0}")] + UnsupportedReasoningEffort(web::model_request_policy::UnsupportedReasoningEffort), + #[error("Bad Request")] BadRequest, @@ -481,6 +486,7 @@ impl IntoResponse for ApiError { ApiError::InferenceCapacity { status, .. } => *status, ApiError::InferenceProvider(error) => error.status(), ApiError::SystemOneRequest(error) => error.status(), + ApiError::UnsupportedReasoningEffort(_) => StatusCode::BAD_REQUEST, ApiError::BadRequest => StatusCode::BAD_REQUEST, ApiError::SessionNotFound => StatusCode::BAD_REQUEST, ApiError::Conflict => StatusCode::CONFLICT, @@ -509,8 +515,17 @@ impl IntoResponse for ApiError { ApiError::ModelNotAvailableOnPlan => Some("model_not_available_on_plan"), ApiError::MessageExceedsContextLimit => Some("message_exceeds_context_limit"), ApiError::SystemOneRequest(error) => Some(error.code()), + ApiError::UnsupportedReasoningEffort(_) => Some("unsupported_value"), _ => None, }; + // OpenAI's `type` and `param` are published only where the rejection + // is one OpenAI itself defines; existing bodies stay byte-identical. + let (error_type, error_param) = match &self { + ApiError::UnsupportedReasoningEffort(error) => { + (Some("invalid_request_error"), Some(error.param())) + } + _ => (None, None), + }; let error_code = match &self { ApiError::SessionNotFound => Some(SESSION_NOT_FOUND_ERROR_CODE), ApiError::BillingCleanupUnavailable => Some("billing_account_deletion_failed"), @@ -525,6 +540,8 @@ impl IntoResponse for ApiError { let error = openai_error_code.map(|code| OpenAIErrorResponse { message: message.clone(), code, + error_type, + param: error_param, }); let mut response = ( status, @@ -613,6 +630,10 @@ pub struct ErrorResponse { struct OpenAIErrorResponse { message: String, code: &'static str, + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + error_type: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + param: Option<&'static str>, } #[cfg(test)] @@ -648,6 +669,25 @@ mod api_error_contract_tests { assert_eq!(body.as_ref(), expected_body); } + #[tokio::test] + async fn unsupported_reasoning_effort_is_an_openai_invalid_request_error() { + let error = ApiError::UnsupportedReasoningEffort( + web::model_request_policy::UnsupportedReasoningEffort::for_test( + "reasoning_effort", + "glm-5-3", + "medium", + vec!["low", "high", "max"], + ), + ); + assert_error_response( + error, + StatusCode::BAD_REQUEST, + br#"{"status":400,"message":"Unsupported value: 'medium' is not supported with the 'glm-5-3' model. Supported values are: 'low', 'high', 'max'.","error":{"message":"Unsupported value: 'medium' is not supported with the 'glm-5-3' model. Supported values are: 'low', 'high', 'max'.","code":"unsupported_value","type":"invalid_request_error","param":"reasoning_effort"}}"#, + Some("unsupported_value"), + ) + .await; + } + #[tokio::test] async fn actionable_inference_errors_add_openai_details_and_preserve_legacy_fields() { let cases: [(ApiError, StatusCode, &[u8], &str); 4] = [ diff --git a/services/opensecret/src/model_config.rs b/services/opensecret/src/model_config.rs index 9bcfbe482..f47d18600 100644 --- a/services/opensecret/src/model_config.rs +++ b/services/opensecret/src/model_config.rs @@ -1,11 +1,227 @@ //! Central model-specific configuration and public model catalog. +use crate::inference::auto_model::auto_model_candidates; +use crate::inference::ModelSelectionMode; use serde_json::{json, Value}; #[derive(Debug, Clone, Copy, PartialEq)] pub struct ModelConfig { pub context_window: usize, pub responses: ResponsesModelConfig, + /// How the model exposes reasoning effort; `None` for models that never + /// reason and for models outside the catalog. + pub reasoning: Option, + /// The model's renderer rejects OpenAI's `developer` role, so requests + /// carry those messages as `system`. + pub developer_role_as_system: bool, +} + +/// One value of OpenAI's `reasoning_effort` enum, ordered from least to most +/// reasoning so an unsupported request can be moved to the nearest effort a +/// model accepts. The wire spelling is `as_str`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[repr(u8)] +pub enum ReasoningEffort { + None, + Minimal, + Low, + Medium, + High, + XHigh, + Max, +} + +impl ReasoningEffort { + /// Every value, lowest effort first. + pub const ALL: [Self; 7] = [ + Self::None, + Self::Minimal, + Self::Low, + Self::Medium, + Self::High, + Self::XHigh, + Self::Max, + ]; + + pub const fn as_str(self) -> &'static str { + match self { + Self::None => "none", + Self::Minimal => "minimal", + Self::Low => "low", + Self::Medium => "medium", + Self::High => "high", + Self::XHigh => "xhigh", + Self::Max => "max", + } + } + + /// Parse the exact OpenAI spelling; anything else is not an effort. + pub fn parse(value: &str) -> Option { + Self::ALL + .into_iter() + .find(|effort| effort.as_str() == value) + } +} + +/// A set of reasoning efforts, small enough to live in `const` model tables. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReasoningEffortSet(u8); + +impl ReasoningEffortSet { + pub const fn of(efforts: &[ReasoningEffort]) -> Self { + let mut bits = 0u8; + let mut index = 0; + while index < efforts.len() { + bits |= 1 << efforts[index] as u8; + index += 1; + } + Self(bits) + } + + pub const fn contains(self, effort: ReasoningEffort) -> bool { + self.0 & (1 << effort as u8) != 0 + } + + pub const fn intersection(self, other: Self) -> Self { + Self(self.0 & other.0) + } + + #[cfg(test)] + pub const fn is_empty(self) -> bool { + self.0 == 0 + } + + /// Members, lowest effort first. + pub fn iter(self) -> impl DoubleEndedIterator { + ReasoningEffort::ALL + .into_iter() + .filter(move |effort| self.contains(*effort)) + } +} + +/// How a model exposes reasoning effort: the catalog `reasoning` object +/// (OpenRouter's `ModelReasoning` shape, the shape third-party catalogs +/// already use for these model families) and the rule every request is +/// checked against after alias resolution. +/// +/// Values were verified live against both providers on 2026-10-07 (Maple +/// issue #1087). Re-verify them whenever a provider upgrades its engine: the +/// level names stay valid across vLLM versions, the budgets behind them move. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ModelReasoning { + /// Reasoning cannot be turned off: `none` is rejected on an explicit model + /// and moved to the lowest accepted effort on an alias. + pub mandatory: bool, + /// The model reasons when a request names no effort. + pub default_enabled: bool, + /// Accepted efforts. `None` when the model has no tiers: any effort other + /// than `none` turns reasoning on. + pub supported_efforts: Option, + /// The effort used when a request names none; `None` for models without + /// tiers. + pub default_effort: Option, +} + +impl ModelReasoning { + const fn tiers( + mandatory: bool, + supported_efforts: ReasoningEffortSet, + default_effort: ReasoningEffort, + ) -> Self { + Self { + mandatory, + default_enabled: true, + supported_efforts: Some(supported_efforts), + default_effort: Some(default_effort), + } + } + + const fn toggle(default_enabled: bool) -> Self { + Self { + mandatory: false, + default_enabled, + supported_efforts: None, + default_effort: None, + } + } + + /// Whether a request may name this effort for the model. + pub fn accepts(self, effort: ReasoningEffort) -> bool { + if effort == ReasoningEffort::None { + return !self.mandatory; + } + self.supported_efforts + .is_none_or(|efforts| efforts.contains(effort)) + } + + /// Every effort a request may name, lowest first. + pub fn accepted_efforts(self) -> Vec { + ReasoningEffort::ALL + .into_iter() + .filter(|effort| self.accepts(*effort)) + .collect() + } + + /// The nearest accepted effort: the requested one when accepted, else the + /// next higher accepted effort, else the next lower one. This is the rule + /// Pi applies on the client; the server applies it to Auto aliases, whose + /// resolved model can change between requests. + pub fn clamp(self, requested: ReasoningEffort) -> ReasoningEffort { + if self.accepts(requested) { + return requested; + } + let accepted = self.accepted_efforts(); + accepted + .iter() + .copied() + .find(|effort| *effort > requested) + .or_else(|| { + accepted + .iter() + .rev() + .copied() + .find(|effort| *effort < requested) + }) + .unwrap_or(requested) + } + + /// What an alias can promise: the efforts every model it may resolve to + /// accepts. The preferred target's defaults stand, since it serves unless + /// it is unavailable. + fn intersect(self, other: Self) -> Self { + Self { + mandatory: self.mandatory || other.mandatory, + default_enabled: self.default_enabled, + supported_efforts: match (self.supported_efforts, other.supported_efforts) { + (Some(mine), Some(theirs)) => Some(mine.intersection(theirs)), + (Some(efforts), None) | (None, Some(efforts)) => Some(efforts), + (None, None) => None, + }, + default_effort: self.default_effort, + } + } + + /// The catalog `reasoning` object. `supported_efforts` lists the highest + /// effort first, as OpenRouter does. + pub fn json(self) -> Value { + let mut value = json!({ + "mandatory": self.mandatory, + "default_enabled": self.default_enabled, + }); + if let Some(efforts) = self.supported_efforts { + value["supported_efforts"] = Value::Array( + efforts + .iter() + .rev() + .map(|effort| json!(effort.as_str())) + .collect(), + ); + } + if let Some(default_effort) = self.default_effort { + value["default_effort"] = json!(default_effort.as_str()); + } + value + } } #[derive(Debug, Clone, Copy, PartialEq)] @@ -296,6 +512,8 @@ impl ModelConfigEntry { config: ModelConfig { context_window, responses, + reasoning: None, + developer_role_as_system: false, }, catalog_metadata: None, } @@ -334,6 +552,8 @@ impl ModelConfigEntry { config: ModelConfig { context_window, responses, + reasoning: None, + developer_role_as_system: false, }, catalog_metadata: None, } @@ -354,6 +574,30 @@ impl ModelConfigEntry { self } + const fn with_reasoning(mut self, reasoning: ModelReasoning) -> Self { + self.config.reasoning = Some(reasoning); + self + } + + const fn with_developer_role_as_system(mut self) -> Self { + self.config.developer_role_as_system = true; + self + } + + /// vLLM has no output cap of its own: input and output share the context + /// window, so the most a request can receive is the window less its prompt. + const fn max_completion_tokens(self) -> usize { + self.config.context_window + } + + /// Catalog fields shared by the public catalog and the OpenAI models list. + fn write_reasoning_metadata(self, value: &mut Value) { + value["max_completion_tokens"] = json!(self.max_completion_tokens()); + if let Some(reasoning) = self.config.reasoning { + value["reasoning"] = reasoning.json(); + } + } + fn catalog_json(self) -> Value { let mut value = json!({ "id": self.id, @@ -375,6 +619,7 @@ impl ModelConfigEntry { "deprecated": self.deprecated, "sort_order": self.sort_order, }); + self.write_reasoning_metadata(&mut value); if let Some(metadata) = self.catalog_metadata { value["input_modalities"] = json!(metadata.input_modalities); @@ -391,7 +636,7 @@ impl ModelConfigEntry { } fn openai_model_json(self) -> Value { - json!({ + let mut value = json!({ "id": self.id, "object": "model", "created": 0, @@ -404,7 +649,9 @@ impl ModelConfigEntry { "access": self.access.as_str(), "capabilities": self.capabilities.json(), "badges": self.badges, - }) + }); + self.write_reasoning_metadata(&mut value); + value } fn tasks(self) -> Vec<&'static str> { @@ -528,12 +775,15 @@ impl ModelCapabilities { } impl ModelAliasEntry { - fn catalog_json(self, alias_targets: ModelAliasTargets) -> Value { - let target_model = alias_targets + fn catalog_json(self, plan: ModelPlan) -> Value { + let target_model = ModelAliasTargets::for_plan(plan) .target_for(self.id) .expect("catalog alias must have a configured target"); let target = model_entry(target_model); - json!({ + let context_window = target + .map(|entry| entry.config.context_window) + .unwrap_or(DEFAULT_CONTEXT_WINDOW); + let mut value = json!({ "id": self.id, "label": self.label, "short_name": self.short_name, @@ -541,15 +791,79 @@ impl ModelAliasEntry { "target_model": target_model, "access": target.map(|entry| entry.access.as_str()).unwrap_or("free"), "capabilities": target.map(|entry| entry.capabilities.json()).unwrap_or_else(|| ModelCapabilities::chat(false, false).json()), - }) + "context_window": context_window, + "max_completion_tokens": context_window, + }); + if let Some(reasoning) = alias_reasoning(self.id, plan) { + value["reasoning"] = reasoning.json(); + } + value } } const DEFAULT_MODEL_CONFIG: ModelConfig = ModelConfig { context_window: DEFAULT_CONTEXT_WINDOW, responses: UNCATALOGED_RESPONSES_MODEL_CONFIG, + reasoning: None, + developer_role_as_system: false, }; +// Reasoning controls each deployed build honors, verified live on both +// providers on 2026-10-07 (issue #1087). Harmony (gpt-oss) rejects every other +// value with a 400 and defaults to medium. +const GPT_OSS_REASONING: ModelReasoning = ModelReasoning::tiers( + true, + ReasoningEffortSet::of(&[ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ]), + ReasoningEffort::Medium, +); + +// Gemma 4 only has a thinking switch: `none` keeps it off, any other effort +// turns it on, and the model itself defaults to off. +const GEMMA4_REASONING: ModelReasoning = ModelReasoning::toggle(false); + +// Kimi K3's renderer accepts low/high/max, treats `none` as thinking off and +// rejects everything else with a 400; it defaults to max. +const KIMI_K3_REASONING: ModelReasoning = ModelReasoning::tiers( + false, + ReasoningEffortSet::of(&[ + ReasoningEffort::Low, + ReasoningEffort::High, + ReasoningEffort::Max, + ]), + ReasoningEffort::Max, +); + +// GLM-5.3 and GLM-5.3-Flash never reject a value: anything but low/high runs +// at max, and every thinking-off control leaks the reasoning into `content` +// on the deployed builds (vLLM fix d95d1dc is unreleased). Reasoning is +// therefore mandatory and unsupported values must be caught here. +const GLM_5_3_REASONING: ModelReasoning = ModelReasoning::tiers( + true, + ReasoningEffortSet::of(&[ + ReasoningEffort::Low, + ReasoningEffort::High, + ReasoningEffort::Max, + ]), + ReasoningEffort::Max, +); + +// DeepSeek V4.1 accepts low/high/xhigh/max, treats `none` as chat mode and +// rejects minimal/medium with a 400; its template default is high. +const DEEPSEEK_V4_1_FLASH_REASONING: ModelReasoning = ModelReasoning::tiers( + false, + ReasoningEffortSet::of(&[ + ReasoningEffort::Low, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ReasoningEffort::Max, + ]), + ReasoningEffort::High, +); + // Each catalog model uses its creator's published sampling and reasoning-history // rules. Change these only from a cited model card, generation config, or // official usage guide. @@ -614,7 +928,8 @@ const MODEL_CONFIGS: &[ModelConfigEntry] = &[ 10, 131_072, GPT_OSS_RESPONSES_MODEL_CONFIG, - ), + ) + .with_reasoning(GPT_OSS_REASONING), ModelConfigEntry::new( "gemma4-31b", "Gemma 4 31B", @@ -629,7 +944,8 @@ const MODEL_CONFIGS: &[ModelConfigEntry] = &[ 20, 262_144, GEMMA4_RESPONSES_MODEL_CONFIG, - ), + ) + .with_reasoning(GEMMA4_REASONING), ModelConfigEntry::new( GLM_5_3_FLASH_MODEL_ID, "GLM-5.3 Flash", @@ -647,6 +963,7 @@ const MODEL_CONFIGS: &[ModelConfigEntry] = &[ 1_048_576, GLM_5_3_RESPONSES_MODEL_CONFIG, ) + .with_reasoning(GLM_5_3_REASONING) .with_catalog_metadata(ModelCatalogMetadata::new( &["text", "image"], &["text"], @@ -668,6 +985,8 @@ const MODEL_CONFIGS: &[ModelConfigEntry] = &[ 262_144, KIMI_K3_RESPONSES_MODEL_CONFIG, ) + .with_reasoning(KIMI_K3_REASONING) + .with_developer_role_as_system() .with_catalog_metadata(ModelCatalogMetadata::new( &["text", "image"], &["text"], @@ -690,6 +1009,7 @@ const MODEL_CONFIGS: &[ModelConfigEntry] = &[ 262_144, GLM_5_3_RESPONSES_MODEL_CONFIG, ) + .with_reasoning(GLM_5_3_REASONING) .with_catalog_provider("continuum", "glm-5.3") .with_catalog_metadata(ModelCatalogMetadata::new(&["text"], &["text"], None, None)), ModelConfigEntry::new( @@ -707,6 +1027,7 @@ const MODEL_CONFIGS: &[ModelConfigEntry] = &[ 1_048_576, DEEPSEEK_V4_1_FLASH_RESPONSES_MODEL_CONFIG, ) + .with_reasoning(DEEPSEEK_V4_1_FLASH_REASONING) .with_catalog_metadata(ModelCatalogMetadata::new( &["text", "image"], &["text"], @@ -741,7 +1062,8 @@ const MODEL_CONFIGS: &[ModelConfigEntry] = &[ 900, 131_072, GPT_OSS_RESPONSES_MODEL_CONFIG, - ), + ) + .with_reasoning(GPT_OSS_REASONING), ]; const MODEL_ALIAS_ENTRIES: &[ModelAliasEntry] = &[ @@ -819,7 +1141,25 @@ pub fn model_reasoning_replay(model: &str, tools_enabled: bool) -> ReasoningRepl model_reasoning_history_strategy(model).replay(tools_enabled) } -pub(crate) fn model_catalog_response(alias_targets: ModelAliasTargets) -> Value { +/// The reasoning controls of a canonical public model, if it reasons. +pub fn model_reasoning(model: &str) -> Option { + model_entry(model).and_then(|entry| entry.config.reasoning) +} + +/// The reasoning an alias publishes: what every model it may resolve to for the +/// plan accepts (its target first, then its Auto alternates). `None` when any +/// candidate never reasons. +pub(crate) fn alias_reasoning(alias: &str, plan: ModelPlan) -> Option { + let mut candidates = + auto_model_candidates(ModelSelectionMode::from_requested_model(alias), plan)?.into_iter(); + let mut reasoning = model_reasoning(candidates.next()?)?; + for candidate in candidates { + reasoning = reasoning.intersect(model_reasoning(candidate)?); + } + Some(reasoning) +} + +pub(crate) fn model_catalog_response(plan: ModelPlan) -> Value { let data = MODEL_CONFIGS .iter() .filter(|entry| entry.listed) @@ -827,7 +1167,7 @@ pub(crate) fn model_catalog_response(alias_targets: ModelAliasTargets) -> Value .collect::>(); let aliases = MODEL_ALIAS_ENTRIES .iter() - .map(|entry| entry.catalog_json(alias_targets)) + .map(|entry| entry.catalog_json(plan)) .collect::>(); json!({ @@ -1196,7 +1536,7 @@ mod tests { fn test_catalog_alias_metadata_matches_resolved_models() { for plan in [ModelPlan::Free, ModelPlan::Paid] { let targets = ModelAliasTargets::for_plan(plan); - let catalog = model_catalog_response(targets); + let catalog = model_catalog_response(plan); for alias in catalog["aliases"].as_array().expect("aliases") { let selector = alias["id"].as_str().expect("alias ID"); let target = targets.resolve(selector); @@ -1263,8 +1603,7 @@ mod tests { #[test] fn test_catalog_alias_metadata_tracks_paid_targets() { - let targets = ModelAliasTargets::for_plan(ModelPlan::Paid); - let catalog = model_catalog_response(targets); + let catalog = model_catalog_response(ModelPlan::Paid); let aliases = catalog["aliases"].as_array().expect("aliases"); let quick = aliases .iter() @@ -1314,7 +1653,7 @@ mod tests { #[test] fn test_catalog_hides_api_only_models_and_includes_aliases() { - let response = model_catalog_response(ModelAliasTargets::default()); + let response = model_catalog_response(ModelPlan::Free); let data = response["data"].as_array().expect("catalog data"); assert!(data.iter().any(|model| model["id"] == QUICK_MODEL_ID)); assert!(!data @@ -1334,7 +1673,7 @@ mod tests { (ModelPlan::Free, QUICK_MODEL_ID, "free"), (ModelPlan::Paid, GLM_5_3_FLASH_MODEL_ID, "pro"), ] { - let catalog = model_catalog_response(ModelAliasTargets::for_plan(plan)); + let catalog = model_catalog_response(plan); let aliases = catalog["aliases"].as_array().expect("aliases"); let quick = aliases .iter() @@ -1367,7 +1706,7 @@ mod tests { #[test] fn test_catalog_advertises_voxtral_as_default_speech_model() { - let response = model_catalog_response(ModelAliasTargets::default()); + let response = model_catalog_response(ModelPlan::Free); assert_eq!(response["audio"]["speech"]["available"], true); assert_eq!(response["audio"]["speech"]["model"], "voxtral-tts"); @@ -1376,7 +1715,7 @@ mod tests { #[test] fn test_catalog_removes_deprecated_kimi_k2_6() { - let catalog = model_catalog_response(ModelAliasTargets::default()); + let catalog = model_catalog_response(ModelPlan::Free); let openai_models = openai_models_response(); assert!(!has_model(&catalog, "kimi-k2-6")); @@ -1386,7 +1725,7 @@ mod tests { #[test] fn test_catalog_adds_glm_5_3_directly_through_continuum_without_changing_aliases() { - let catalog = model_catalog_response(ModelAliasTargets::for_plan(ModelPlan::Paid)); + let catalog = model_catalog_response(ModelPlan::Paid); let glm = catalog_model(&catalog, GLM_5_3_MODEL_ID); assert_eq!(glm["provider"], "continuum"); @@ -1410,7 +1749,7 @@ mod tests { #[test] fn test_catalog_adds_glm_5_3_flash_through_tinfoil_with_image_and_shared_1m_context() { - let catalog = model_catalog_response(ModelAliasTargets::for_plan(ModelPlan::Paid)); + let catalog = model_catalog_response(ModelPlan::Paid); let glm = catalog_model(&catalog, GLM_5_3_FLASH_MODEL_ID); assert_eq!(glm["provider"], "tinfoil"); @@ -1429,7 +1768,7 @@ mod tests { #[test] fn test_catalog_adds_deepseek_v4_1_flash_through_tinfoil_with_image_and_1m_context() { - let catalog = model_catalog_response(ModelAliasTargets::for_plan(ModelPlan::Paid)); + let catalog = model_catalog_response(ModelPlan::Paid); let deepseek = catalog_model(&catalog, DEEPSEEK_V4_1_FLASH_MODEL_ID); assert_eq!(deepseek["provider"], "tinfoil"); @@ -1448,7 +1787,7 @@ mod tests { #[test] fn test_new_tinfoil_models_are_generally_listed() { - let catalog = model_catalog_response(ModelAliasTargets::default()); + let catalog = model_catalog_response(ModelPlan::Free); let openai_models = openai_models_response(); for model in [ @@ -1471,24 +1810,22 @@ mod tests { ]; assert_eq!( - model_ids_with_badge(&model_catalog_response(ModelAliasTargets::default()), "New"), + model_ids_with_badge(&model_catalog_response(ModelPlan::Free), "New"), expected ); assert_eq!( model_ids_with_badge(&openai_models_response(), "New"), expected ); - assert!(model_ids_with_badge( - &model_catalog_response(ModelAliasTargets::default()), - "Reasoning" - ) - .is_empty()); + assert!( + model_ids_with_badge(&model_catalog_response(ModelPlan::Free), "Reasoning").is_empty() + ); assert!(model_ids_with_badge(&openai_models_response(), "Reasoning").is_empty()); } #[test] fn test_enriched_catalog_has_verified_new_model_metadata() { - let catalog = model_catalog_response(ModelAliasTargets::default()); + let catalog = model_catalog_response(ModelPlan::Free); let kimi = catalog_model(&catalog, "kimi-k3"); assert_eq!(kimi["access"], "pro"); assert_eq!(kimi["provider_id"], "kimi-k3"); @@ -1523,4 +1860,231 @@ mod tests { assert!(minimal_kimi.get("input_modalities").is_none()); assert!(minimal_kimi.get("parameter_size").is_none()); } + + #[test] + fn test_catalog_publishes_the_verified_reasoning_controls_per_model() { + // (model, mandatory, default_enabled, supported efforts highest first, default) + type Expected = ( + &'static str, + bool, + bool, + Option<&'static [&'static str]>, + Option<&'static str>, + ); + let expected: [Expected; 8] = [ + ( + "gpt-oss-120b", + true, + true, + Some(&["high", "medium", "low"]), + Some("medium"), + ), + ( + "gpt-oss-safeguard-120b", + true, + true, + Some(&["high", "medium", "low"]), + Some("medium"), + ), + ("gemma4-31b", false, false, None, None), + ( + "kimi-k3", + false, + true, + Some(&["max", "high", "low"]), + Some("max"), + ), + ( + "glm-5-3", + true, + true, + Some(&["max", "high", "low"]), + Some("max"), + ), + ( + "glm-5-3-flash", + true, + true, + Some(&["max", "high", "low"]), + Some("max"), + ), + ( + "deepseek-v4-1-flash", + false, + true, + Some(&["max", "xhigh", "high", "low"]), + Some("high"), + ), + ("llama3-3-70b", false, false, None, None), + ]; + assert_eq!(expected.len(), MODEL_CONFIGS.len()); + let models = openai_models_response(); + + for (model, mandatory, default_enabled, supported, default_effort) in expected { + let entry = model_entry(model).expect("catalog model"); + let listed = models["data"] + .as_array() + .unwrap() + .iter() + .find(|listed| listed["id"] == model) + .expect("listed model"); + assert_eq!( + listed["max_completion_tokens"], + json!(entry.config.context_window), + "{model}" + ); + match entry.config.reasoning { + None => { + assert!(!entry.capabilities.reasoning, "{model}"); + assert!(listed.get("reasoning").is_none(), "{model}"); + } + Some(reasoning) => { + assert!(entry.capabilities.reasoning, "{model}"); + assert_eq!(reasoning.mandatory, mandatory, "{model}"); + assert_eq!(reasoning.default_enabled, default_enabled, "{model}"); + let published = &listed["reasoning"]; + assert_eq!(published["mandatory"], json!(mandatory), "{model}"); + assert_eq!( + published["default_enabled"], + json!(default_enabled), + "{model}" + ); + assert_eq!( + published.get("supported_efforts").cloned(), + supported.map(|efforts| json!(efforts)), + "{model}" + ); + assert_eq!( + published.get("default_effort").cloned(), + default_effort.map(|effort| json!(effort)), + "{model}" + ); + } + } + } + } + + #[test] + fn test_reasoning_controls_match_between_catalog_and_models_endpoints() { + let catalog = model_catalog_response(ModelPlan::Paid); + let models = openai_models_response(); + for model in catalog["data"].as_array().expect("catalog data") { + let listed = models["data"] + .as_array() + .unwrap() + .iter() + .find(|listed| listed["id"] == model["id"]) + .expect("every catalog model is listed"); + assert_eq!( + model.get("reasoning"), + listed.get("reasoning"), + "{}", + model["id"] + ); + assert_eq!( + model["max_completion_tokens"], listed["max_completion_tokens"], + "{}", + model["id"] + ); + } + } + + #[test] + fn test_reasoning_effort_round_trips_openai_spellings() { + for effort in ReasoningEffort::ALL { + assert_eq!(ReasoningEffort::parse(effort.as_str()), Some(effort)); + } + assert_eq!(ReasoningEffort::parse("ultra"), None); + assert_eq!(ReasoningEffort::parse("Low"), None); + assert!(ReasoningEffort::None < ReasoningEffort::Minimal); + assert!(ReasoningEffort::XHigh < ReasoningEffort::Max); + } + + #[test] + fn test_reasoning_accepts_and_clamps_per_model() { + let glm = model_reasoning(GLM_5_3_MODEL_ID).unwrap(); + assert!(!glm.accepts(ReasoningEffort::None)); + assert!(!glm.accepts(ReasoningEffort::Medium)); + assert!(glm.accepts(ReasoningEffort::Max)); + assert_eq!(glm.clamp(ReasoningEffort::None), ReasoningEffort::Low); + assert_eq!(glm.clamp(ReasoningEffort::Minimal), ReasoningEffort::Low); + assert_eq!(glm.clamp(ReasoningEffort::Medium), ReasoningEffort::High); + assert_eq!(glm.clamp(ReasoningEffort::XHigh), ReasoningEffort::Max); + assert_eq!(glm.clamp(ReasoningEffort::High), ReasoningEffort::High); + + let gpt_oss = model_reasoning(QUICK_MODEL_ID).unwrap(); + assert_eq!(gpt_oss.clamp(ReasoningEffort::Max), ReasoningEffort::High); + assert_eq!(gpt_oss.clamp(ReasoningEffort::XHigh), ReasoningEffort::High); + assert_eq!(gpt_oss.clamp(ReasoningEffort::None), ReasoningEffort::Low); + + let kimi = model_reasoning(KIMI_K3_MODEL_ID).unwrap(); + assert!(kimi.accepts(ReasoningEffort::None)); + assert_eq!(kimi.clamp(ReasoningEffort::Medium), ReasoningEffort::High); + assert_eq!( + kimi.accepted_efforts(), + vec![ + ReasoningEffort::None, + ReasoningEffort::Low, + ReasoningEffort::High, + ReasoningEffort::Max + ] + ); + + let gemma = model_reasoning("gemma4-31b").unwrap(); + for effort in ReasoningEffort::ALL { + assert!(gemma.accepts(effort), "{effort:?}"); + } + assert_eq!(model_reasoning("llama3-3-70b"), None); + assert_eq!(model_reasoning(AUTO_QUICK_MODEL_ID), None); + } + + #[test] + fn test_alias_reasoning_is_what_every_candidate_accepts() { + // Paid Quick may run on GLM-5.3-Flash or DeepSeek: xhigh and none are + // not shared, so neither is promised; Flash's mandatory reasoning wins. + let paid_quick = alias_reasoning(AUTO_QUICK_MODEL_ID, ModelPlan::Paid).unwrap(); + assert_eq!( + paid_quick.json(), + json!({ + "mandatory": true, + "default_enabled": true, + "supported_efforts": ["max", "high", "low"], + "default_effort": "max" + }) + ); + // Paid Powerful may run on GLM-5.3 or Kimi K3. + let paid_powerful = alias_reasoning(AUTO_POWERFUL_MODEL_ID, ModelPlan::Paid).unwrap(); + assert_eq!(paid_powerful, paid_quick); + // Free aliases have no alternates and publish their target's controls. + assert_eq!( + alias_reasoning(AUTO_QUICK_MODEL_ID, ModelPlan::Free), + model_reasoning(QUICK_MODEL_ID) + ); + assert_eq!( + alias_reasoning(AUTO_POWERFUL_MODEL_ID, ModelPlan::Free), + model_reasoning(GLM_5_3_MODEL_ID) + ); + assert_eq!(alias_reasoning(GLM_5_3_MODEL_ID, ModelPlan::Paid), None); + + for plan in [ModelPlan::Free, ModelPlan::Paid] { + let catalog = model_catalog_response(plan); + for alias in catalog["aliases"].as_array().expect("aliases") { + let selector = alias["id"].as_str().expect("alias ID"); + let reasoning = alias_reasoning(selector, plan).expect("alias reasoning"); + assert_eq!(alias["reasoning"], reasoning.json(), "{selector} {plan:?}"); + assert!( + reasoning + .supported_efforts + .is_none_or(|efforts| !efforts.is_empty()), + "{selector} {plan:?} must accept some effort" + ); + let target = catalog_model(&catalog, alias["target_model"].as_str().unwrap()); + assert_eq!(alias["context_window"], target["context_window"]); + assert_eq!( + alias["max_completion_tokens"], + target["max_completion_tokens"] + ); + } + } + } } diff --git a/services/opensecret/src/web/mod.rs b/services/opensecret/src/web/mod.rs index eaed232b3..a15c0cbb6 100644 --- a/services/opensecret/src/web/mod.rs +++ b/services/opensecret/src/web/mod.rs @@ -3,6 +3,7 @@ pub mod audio_utils; pub mod encryption_middleware; mod health_routes; pub mod login_routes; +pub(crate) mod model_request_policy; mod native_handoff_routes; mod oauth_routes; mod openai; diff --git a/services/opensecret/src/web/model_request_policy.rs b/services/opensecret/src/web/model_request_policy.rs new file mode 100644 index 000000000..e57e789c9 --- /dev/null +++ b/services/opensecret/src/web/model_request_policy.rs @@ -0,0 +1,502 @@ +//! Per-model normalization of client inference requests before a provider sees +//! them: reasoning-effort validation and clamping, thinking-off switches a +//! model cannot honor, and message roles a model's renderer rejects. +//! +//! Every rule reads one table, [`crate::model_config::ModelReasoning`], which +//! also produces the catalog `reasoning` object. Chat Completions and Responses +//! both call in here after alias resolution, so a level means the same thing +//! on every surface and matches what the catalog promised. + +use crate::model_config::{model_config, model_reasoning, ReasoningEffort}; +use crate::ApiError; +use serde_json::{json, Map, Value}; +use std::fmt; +use tracing::debug; + +/// The request field that carries the effort, named as OpenAI names it on +/// each API. +pub(crate) const CHAT_REASONING_EFFORT_PARAM: &str = "reasoning_effort"; +pub(crate) const RESPONSES_REASONING_EFFORT_PARAM: &str = "reasoning.effort"; + +/// Template switches that turn thinking off on models that support it. +const THINKING_OFF_SWITCHES: [&str; 2] = ["enable_thinking", "thinking"]; + +/// Longest client value echoed back in an error. +const MAX_ECHOED_VALUE_CHARS: usize = 32; + +/// How the caller chose the model. An explicit model gets exactly what it +/// asked for or a 400, as OpenAI does. An Auto alias is moved to the nearest +/// effort the resolved model accepts, because a health fallback to another +/// model must never fail a running task. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ModelSelection { + Explicit, + Alias, +} + +impl ModelSelection { + pub(crate) fn for_request(requested_model: &str, alias_target: &str) -> Self { + if requested_model == alias_target { + Self::Explicit + } else { + Self::Alias + } + } +} + +/// The effort a request ends up with for the resolved model. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum EffectiveReasoningEffort { + /// The request named no effort, or the model has no reasoning controls; + /// the body is forwarded as the client sent it. + Unchanged, + /// The request names, or is moved to, this effort. + Set(ReasoningEffort), +} + +/// A reasoning effort the resolved model does not accept. Rendered in +/// OpenAI's wording so clients that already parse OpenAI's rejections learn +/// the accepted values from the message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnsupportedReasoningEffort { + param: &'static str, + model: String, + value: String, + supported: Vec<&'static str>, +} + +impl UnsupportedReasoningEffort { + /// The OpenAI `error.param` naming the rejected field. + pub(crate) fn param(&self) -> &'static str { + self.param + } + + #[cfg(test)] + pub(crate) fn for_test( + param: &'static str, + model: &str, + value: &str, + supported: Vec<&'static str>, + ) -> Self { + Self { + param, + model: model.to_string(), + value: value.to_string(), + supported, + } + } +} + +impl fmt::Display for UnsupportedReasoningEffort { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let supported = self + .supported + .iter() + .map(|value| format!("'{value}'")) + .collect::>() + .join(", "); + write!( + f, + "Unsupported value: '{}' is not supported with the '{}' model. Supported values are: {}.", + self.value, self.model, supported + ) + } +} + +/// The client's value, printable and bounded, for the error message only. +fn describe_value(value: &Value) -> String { + let raw = match value { + Value::String(text) => text.clone(), + other => other.to_string(), + }; + raw.chars() + .filter(|c| c.is_ascii_graphic() || *c == ' ') + .take(MAX_ECHOED_VALUE_CHARS) + .collect() +} + +/// Validate the effort a client named against the resolved model. +/// +/// * absent or `null`: the model's default stands; +/// * a model without reasoning controls: forwarded untouched, which is what +/// every deployed build does with the field; +/// * an accepted effort: kept; +/// * anything else: a 400 for an explicit model, the nearest accepted effort +/// for an alias. A value that is not an effort at all is a 400 either way. +pub(crate) fn resolve_reasoning_effort( + model: &str, + requested: Option<&Value>, + selection: ModelSelection, + param: &'static str, +) -> Result { + let Some(requested) = requested.filter(|value| !value.is_null()) else { + return Ok(EffectiveReasoningEffort::Unchanged); + }; + let Some(reasoning) = model_reasoning(model) else { + return Ok(EffectiveReasoningEffort::Unchanged); + }; + let parsed = requested.as_str().and_then(ReasoningEffort::parse); + match (parsed, selection) { + (Some(effort), _) if reasoning.accepts(effort) => Ok(EffectiveReasoningEffort::Set(effort)), + (Some(effort), ModelSelection::Alias) => { + let clamped = reasoning.clamp(effort); + debug!( + "Moved alias reasoning effort {} to {} for {}", + effort.as_str(), + clamped.as_str(), + model + ); + Ok(EffectiveReasoningEffort::Set(clamped)) + } + (Some(_), ModelSelection::Explicit) | (None, _) => Err( + ApiError::UnsupportedReasoningEffort(UnsupportedReasoningEffort { + param, + model: model.to_string(), + value: describe_value(requested), + supported: reasoning + .accepted_efforts() + .into_iter() + .map(ReasoningEffort::as_str) + .collect(), + }), + ), + } +} + +/// The effort for a model-turn request whose model was decided after the +/// request was validated (an Auto alias may resolve to an alternate model). +/// Never fails: values that are not efforts were rejected on arrival. +pub(crate) fn clamp_reasoning_effort( + model: &str, + requested: Option<&Value>, + param: &'static str, +) -> EffectiveReasoningEffort { + resolve_reasoning_effort(model, requested, ModelSelection::Alias, param) + .unwrap_or(EffectiveReasoningEffort::Unchanged) +} + +/// Rewrite a Chat Completions body for the resolved model: +/// +/// * write the effective `reasoning_effort`; +/// * drop template switches that would turn thinking off on a model whose +/// reasoning is mandatory (on the deployed GLM builds they make the +/// reasoning come back as `content`); +/// * send `developer` messages as `system` where the renderer rejects them. +pub(crate) fn apply_model_request_policy( + body: &mut Map, + model: &str, + effort: EffectiveReasoningEffort, +) { + if let EffectiveReasoningEffort::Set(effort) = effort { + body.insert( + CHAT_REASONING_EFFORT_PARAM.to_string(), + json!(effort.as_str()), + ); + } + let config = model_config(model); + if config + .reasoning + .is_some_and(|reasoning| reasoning.mandatory) + { + strip_thinking_off_switches(body); + } + if config.developer_role_as_system { + rename_developer_role(body); + } +} + +fn strip_thinking_off_switches(body: &mut Map) { + let Some(kwargs) = body + .get_mut("chat_template_kwargs") + .and_then(Value::as_object_mut) + else { + return; + }; + for key in THINKING_OFF_SWITCHES { + if kwargs.get(key).and_then(Value::as_bool) == Some(false) { + kwargs.remove(key); + debug!("Dropped chat_template_kwargs.{key}=false: the model's reasoning is mandatory"); + } + } + if kwargs.is_empty() { + body.remove("chat_template_kwargs"); + } +} + +fn rename_developer_role(body: &mut Map) { + let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else { + return; + }; + for message in messages { + if message.get("role").and_then(Value::as_str) == Some("developer") { + message["role"] = json!("system"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model_config::{ + DEEPSEEK_V4_1_FLASH_MODEL_ID, GLM_5_3_FLASH_MODEL_ID, GLM_5_3_MODEL_ID, KIMI_K3_MODEL_ID, + QUICK_MODEL_ID, + }; + + const REASONING_MODELS: [&str; 7] = [ + QUICK_MODEL_ID, + "gpt-oss-safeguard-120b", + "gemma4-31b", + KIMI_K3_MODEL_ID, + GLM_5_3_MODEL_ID, + GLM_5_3_FLASH_MODEL_ID, + DEEPSEEK_V4_1_FLASH_MODEL_ID, + ]; + + fn resolve( + model: &str, + value: Value, + selection: ModelSelection, + ) -> Result { + resolve_reasoning_effort(model, Some(&value), selection, CHAT_REASONING_EFFORT_PARAM) + .map_err(|error| error.to_string()) + } + + #[test] + fn explicit_models_accept_exactly_the_catalog_efforts_and_reject_the_rest() { + for model in REASONING_MODELS { + let reasoning = model_reasoning(model).expect("reasoning model"); + for effort in ReasoningEffort::ALL { + let outcome = resolve(model, json!(effort.as_str()), ModelSelection::Explicit); + if reasoning.accepts(effort) { + assert_eq!( + outcome, + Ok(EffectiveReasoningEffort::Set(effort)), + "{model} {effort:?}" + ); + } else { + let message = outcome.expect_err(&format!("{model} must reject {effort:?}")); + assert!(message.starts_with(&format!( + "Unsupported value: '{}' is not supported with the '{model}' model. Supported values are: ", + effort.as_str() + )), "{message}"); + } + } + } + } + + #[test] + fn rejections_list_the_accepted_values_in_openai_wording() { + let message = + resolve(GLM_5_3_MODEL_ID, json!("none"), ModelSelection::Explicit).unwrap_err(); + assert_eq!( + message, + "Unsupported value: 'none' is not supported with the 'glm-5-3' model. Supported values are: 'low', 'high', 'max'." + ); + let message = + resolve(KIMI_K3_MODEL_ID, json!("medium"), ModelSelection::Explicit).unwrap_err(); + assert_eq!( + message, + "Unsupported value: 'medium' is not supported with the 'kimi-k3' model. Supported values are: 'none', 'low', 'high', 'max'." + ); + } + + #[test] + fn aliases_move_to_the_nearest_accepted_effort() { + for (model, requested, expected) in [ + (GLM_5_3_MODEL_ID, "medium", "high"), + (GLM_5_3_MODEL_ID, "minimal", "low"), + (GLM_5_3_MODEL_ID, "none", "low"), + (GLM_5_3_MODEL_ID, "xhigh", "max"), + (GLM_5_3_FLASH_MODEL_ID, "none", "low"), + (QUICK_MODEL_ID, "none", "low"), + (QUICK_MODEL_ID, "minimal", "low"), + (QUICK_MODEL_ID, "xhigh", "high"), + (QUICK_MODEL_ID, "max", "high"), + (KIMI_K3_MODEL_ID, "medium", "high"), + (KIMI_K3_MODEL_ID, "none", "none"), + (DEEPSEEK_V4_1_FLASH_MODEL_ID, "minimal", "low"), + (DEEPSEEK_V4_1_FLASH_MODEL_ID, "medium", "high"), + ("gemma4-31b", "none", "none"), + ("gemma4-31b", "medium", "medium"), + ] { + let expected = ReasoningEffort::parse(expected).unwrap(); + assert_eq!( + resolve(model, json!(requested), ModelSelection::Alias), + Ok(EffectiveReasoningEffort::Set(expected)), + "{model} {requested}" + ); + } + } + + #[test] + fn values_that_are_not_efforts_are_rejected_on_every_selection() { + for selection in [ModelSelection::Explicit, ModelSelection::Alias] { + assert!(resolve(GLM_5_3_MODEL_ID, json!("ultra"), selection).is_err()); + assert!(resolve(GLM_5_3_MODEL_ID, json!(50), selection).is_err()); + assert!(resolve(GLM_5_3_MODEL_ID, json!({"effort": "low"}), selection).is_err()); + } + let message = resolve( + GLM_5_3_MODEL_ID, + json!("ultra\n\u{7}