diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 211003c75..670fe2e98 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1,10 +1,11 @@ import type { ProviderAdapter } from "./base"; import { debugDroppedFrame } from "../debug"; -import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxToolCall, OcxUsage } from "../types"; -import { namespacedToolName } from "../types"; +import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall, OcxUsage } from "../types"; +import { modelInList, namespacedToolName } from "../types"; +import { mapReasoningEffort } from "../reasoning-effort"; import { contentPartsToText } from "./image"; -function messagesToChatFormat(parsed: OcxParsedRequest): unknown[] { +function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] { const out: unknown[] = []; const { context, options } = parsed; let pendingToolCallIds = new Set(); @@ -46,11 +47,16 @@ function messagesToChatFormat(parsed: OcxParsedRequest): unknown[] { case "assistant": { const aMsg = msg as OcxAssistantMessage; const textParts = aMsg.content.filter(p => p.type === "text") as OcxTextContent[]; + const thinkingParts = aMsg.content.filter(p => p.type === "thinking") as OcxThinkingContent[]; const toolCalls = aMsg.content.filter(p => p.type === "toolCall") as OcxToolCall[]; const chatMsg: Record = { role: "assistant" }; if (textParts.length > 0) { chatMsg.content = textParts.map(p => p.text).join(""); } + const reasoningContent = thinkingParts.map(p => p.thinking).join(""); + if (reasoningContent.length > 0 && modelInList(provider.preserveReasoningContentModels, parsed.modelId)) { + chatMsg.reasoning_content = reasoningContent; + } if (toolCalls.length > 0) { chatMsg.tool_calls = toolCalls.map(tc => ({ id: tc.id, @@ -59,9 +65,12 @@ function messagesToChatFormat(parsed: OcxParsedRequest): unknown[] { })); if (!chatMsg.content) chatMsg.content = null; } - // Skip empty assistant messages (e.g. reasoning-only history items): chat APIs - // like DeepSeek reject an assistant message with neither content nor tool_calls. - if (chatMsg.content === undefined && chatMsg.tool_calls === undefined) break; + if (chatMsg.reasoning_content !== undefined && chatMsg.content === undefined && chatMsg.tool_calls === undefined) { + chatMsg.content = ""; + } + // Skip empty assistant messages: chat APIs like DeepSeek reject an assistant message + // with neither content, tool calls, nor a provider-supported reasoning_content field. + if (chatMsg.content === undefined && chatMsg.tool_calls === undefined && chatMsg.reasoning_content === undefined) break; out.push(chatMsg); pendingToolCallIds = new Set(toolCalls.map(tc => tc.id).filter(Boolean)); break; @@ -141,7 +150,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd name: "openai-chat", buildRequest(parsed: OcxParsedRequest) { - const messages = messagesToChatFormat(parsed); + const messages = messagesToChatFormat(parsed, provider); const tools = toolsToChatFormat(parsed); const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice); @@ -151,22 +160,27 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd stream: parsed.stream, }; if (tools) body.tools = tools; - if (toolChoice !== undefined) body.tool_choice = toolChoice; + if (toolChoice !== undefined) { + body.tool_choice = modelInList(provider.autoToolChoiceOnlyModels, parsed.modelId) + ? (toolChoice === "none" ? "none" : "auto") + : toolChoice; + } if (parsed.options.maxOutputTokens !== undefined) body.max_tokens = parsed.options.maxOutputTokens; - if (parsed.options.temperature !== undefined) body.temperature = parsed.options.temperature; - if (parsed.options.topP !== undefined) body.top_p = parsed.options.topP; + if (parsed.options.temperature !== undefined && !modelInList(provider.noTemperatureModels, parsed.modelId)) { + body.temperature = parsed.options.temperature; + } + if (parsed.options.topP !== undefined && !modelInList(provider.noTopPModels, parsed.modelId)) { + body.top_p = parsed.options.topP; + } if (parsed.options.stopSequences !== undefined) body.stop = parsed.options.stopSequences; - // Some models reject a reasoning/thinking param entirely (e.g. xAI grok-build-0.1, - // grok-composer-2.5-fast). Drop reasoning_effort for them even if Codex selected an effort. - if (parsed.options.reasoning !== undefined && !provider.noReasoningModels?.includes(parsed.modelId)) { - // Forward the reasoning ladder (low/medium/high/xhigh) as-is. "minimal" (Codex-native lowest, - // widely unsupported downstream) maps to "low"; "max" isn't a real tier (no longer advertised) - // so it folds to "xhigh". - const r = parsed.options.reasoning; - body.reasoning_effort = r === "minimal" ? "low" : r === "max" ? "xhigh" : r; + const reasoningEffort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning); + if (reasoningEffort !== undefined) body.reasoning_effort = reasoningEffort; + if (parsed.options.presencePenalty !== undefined && !modelInList(provider.noPenaltyModels, parsed.modelId)) { + body.presence_penalty = parsed.options.presencePenalty; + } + if (parsed.options.frequencyPenalty !== undefined && !modelInList(provider.noPenaltyModels, parsed.modelId)) { + body.frequency_penalty = parsed.options.frequencyPenalty; } - if (parsed.options.presencePenalty !== undefined) body.presence_penalty = parsed.options.presencePenalty; - if (parsed.options.frequencyPenalty !== undefined) body.frequency_penalty = parsed.options.frequencyPenalty; if (parsed.stream) { body.stream_options = { include_usage: true }; diff --git a/src/codex-catalog.ts b/src/codex-catalog.ts index 8c4eb940a..9d52c99b3 100644 --- a/src/codex-catalog.ts +++ b/src/codex-catalog.ts @@ -6,6 +6,7 @@ import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readR import { DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, setCached } from "./model-cache"; import { buildModelsRequest, resolveModelsAuthToken } from "./oauth/index"; import type { OcxConfig, OcxProviderConfig } from "./types"; +import { CODEX_REASONING_LEVELS, configuredReasoningEfforts, sanitizeCodexReasoningEfforts } from "./reasoning-effort"; import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJawcodeModelMetadata, resolveJawcodeProvider } from "./generated/jawcode-model-metadata"; import { shouldCaseFoldMetadataModelId } from "./providers/derive"; @@ -33,7 +34,7 @@ export function nativeOpenAiSlugs(): string[] { return live.length > 0 ? live : NATIVE_OPENAI_MODELS; } -export interface CatalogModel { id: string; provider: string; owned_by?: string; } +export interface CatalogModel { id: string; provider: string; owned_by?: string; reasoningEfforts?: string[]; contextWindow?: number; inputModalities?: string[]; } type RawEntry = Record; const JAWCODE_CATALOG_AUGMENT_PROVIDERS = new Set(["opencode-go"]); @@ -171,19 +172,42 @@ export function loadCatalogTemplate(): RawEntry | null { } /** - * The reasoning ladder advertised for routed models in Codex's picker: low → medium → high → xhigh. - * This matches Codex's NATIVE catalog exactly — Codex's strict parser rejects an unknown effort like - * `max`, so it must not be advertised here. (Previously routed models were clamped down to - * low/medium/high, which dropped the `xhigh` that Codex does support.) + * Codex only accepts its native labels in the catalog. Provider-specific wire values (e.g. Z.AI + * `max`) are mapped at request time by src/reasoning-effort.ts, never advertised directly here. */ -const ROUTED_REASONING_LEVELS: { effort: string; description: string }[] = [ - { effort: "low", description: "Fast responses with lighter reasoning" }, - { effort: "medium", description: "Balances speed and reasoning depth" }, - { effort: "high", description: "Greater reasoning depth for complex problems" }, - { effort: "xhigh", description: "Extended reasoning for the hardest problems" }, -]; +const ROUTED_REASONING_LEVELS = CODEX_REASONING_LEVELS; + +function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel): void { + if (!model) return; + if (typeof model.contextWindow === "number" && model.contextWindow > 0) { + entry.context_window = model.contextWindow; + entry.max_context_window = model.contextWindow; + entry.auto_compact_token_limit = Math.floor(model.contextWindow * 0.9); + } + if (Array.isArray(model.inputModalities) && model.inputModalities.length > 0) { + entry.input_modalities = model.inputModalities; + } +} + +function applyReasoningLevels(entry: RawEntry, effortsOverride?: string[]): void { + const efforts = sanitizeCodexReasoningEfforts(effortsOverride) ?? ROUTED_REASONING_LEVELS.map(l => l.effort); + const byEffort = new Map( + (Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels : []) + .map((l: { effort?: string }) => [l.effort, l]), + ); + entry.supported_reasoning_levels = efforts.map(effort => { + const native = byEffort.get(effort); + if (native) return native; + return ROUTED_REASONING_LEVELS.find(l => l.effort === effort) ?? { effort, description: `${effort} reasoning` }; + }); + if (efforts.length === 0) { + delete entry.default_reasoning_level; + return; + } + entry.default_reasoning_level = efforts.includes("medium") ? "medium" : efforts.includes("high") ? "high" : efforts[0]; +} -function deriveEntry(template: RawEntry | null, slug: string, desc: string, priority: number): RawEntry { +function deriveEntry(template: RawEntry | null, slug: string, desc: string, priority: number, model?: CatalogModel): RawEntry { if (template) { const e = JSON.parse(JSON.stringify(template)) as RawEntry; e.slug = slug; @@ -203,28 +227,24 @@ function deriveEntry(template: RawEntry | null, slug: string, desc: string, prio `You are a coding agent powered by the ${modelName} model, served through the opencodex proxy. Do not claim to be GPT-5 or made by OpenAI.`, ); } - // Reuse the template's level objects where they exist (correct shape/fields), synthesize the rest. - const byEffort = new Map( - (Array.isArray(e.supported_reasoning_levels) ? e.supported_reasoning_levels : []) - .map((l: { effort?: string }) => [l.effort, l]), - ); - e.supported_reasoning_levels = ROUTED_REASONING_LEVELS.map(l => byEffort.get(l.effort) ?? { ...l }); - e.default_reasoning_level = "medium"; + applyReasoningLevels(e, model?.reasoningEfforts); normalizeRoutedCatalogEntry(e); applyJawcodeCatalogMetadata(e, slug); + applyCatalogModelMetadata(e, model); } return ensureStrictCatalogFields(normalizeServiceTiers(e)); } // Fallback when no template is available (best-effort; strict parser may need more). const entry: RawEntry = { slug, display_name: slug, description: desc, - default_reasoning_level: "medium", - supported_reasoning_levels: ROUTED_REASONING_LEVELS.map(l => ({ ...l })), shell_type: "shell_command", visibility: "list", supported_in_api: true, priority, base_instructions: "You are a helpful coding assistant.", ...(slug.includes("/") ? { web_search_tool_type: "text_and_image", supports_search_tool: true } : {}), }; + if (slug.includes("/")) applyReasoningLevels(entry, model?.reasoningEfforts); + else applyReasoningLevels(entry); applyJawcodeCatalogMetadata(entry, slug); + applyCatalogModelMetadata(entry, model); return ensureStrictCatalogFields(normalizeServiceTiers(entry)); } @@ -247,7 +267,7 @@ export function buildCatalogEntries(template: RawEntry | null, gptSlugs: string[ } for (const m of goModels) { const slug = `${m.provider}/${m.id}`; - const e = deriveEntry(template, slug, `Routed via opencodex → ${m.provider} (${m.owned_by ?? m.provider}).`, 5); + const e = deriveEntry(template, slug, `Routed via opencodex → ${m.provider} (${m.owned_by ?? m.provider}).`, 5, m); if (rank.has(slug)) e.priority = rank.get(slug)!; out.push(e); } @@ -285,6 +305,61 @@ function readNativeBaseline(): Map { return out; } + +type ProviderModelsApiItem = { + id: string; + owned_by?: string; + max_model_len?: number; + metadata?: { + capabilities?: Record; + limits?: Record; + }; +}; + +function catalogHintsFromProviderConfig(name: string, prov: OcxProviderConfig, id: string): Partial { + void name; + const reasoningEfforts = configuredReasoningEfforts(prov, id); + return { + ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}), + }; +} + +function applyConfigHintsToCachedModels(name: string, prov: OcxProviderConfig, models: CatalogModel[]): CatalogModel[] { + return models.map(model => ({ + ...catalogHintsFromProviderConfig(name, prov, model.id), + ...model, + })); +} + +function isGlm52ModelId(id: string): boolean { + const normalized = id.toLowerCase(); + return normalized === "glm-5.2" || normalized === "glm-5.2[1m]"; +} + +function catalogHintsFromModelsApiItem(providerName: string, item: ProviderModelsApiItem): Partial { + const capabilities = item.metadata?.capabilities; + const limits = item.metadata?.limits; + const contextWindow = + typeof limits?.max_context_length === "number" ? limits.max_context_length + : typeof item.max_model_len === "number" ? item.max_model_len + : undefined; + const reasoningEfforts = capabilities && typeof capabilities.reasoning_effort === "boolean" + ? (capabilities.reasoning_effort + ? ((providerName === "neuralwatt" || providerName === "zai") && isGlm52ModelId(item.id) + ? ["low", "medium", "high", "xhigh"] + : ["low", "medium", "high"]) + : []) + : undefined; + const inputModalities = capabilities && typeof capabilities.vision === "boolean" + ? (capabilities.vision ? ["text", "image"] : ["text"]) + : undefined; + return { + ...(contextWindow && contextWindow > 0 ? { contextWindow } : {}), + ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}), + ...(inputModalities ? { inputModalities } : {}), + }; +} + /** * Fetch a provider's `/models` (openai-chat style) with a TTL cache + stale fallback. Skips * forward-auth providers. Fresh cache → no network; live fetch → cache the merged result; @@ -296,21 +371,35 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs: const apiKey = await resolveModelsAuthToken(name, prov); if (prov.authMode === "oauth" && !apiKey) return []; // not logged in → skip const fresh = getFreshCached(name, ttlMs); - if (fresh) return fresh; // dedups Codex's frequent /v1/models polling within the TTL - const configured: CatalogModel[] = (prov.models ?? []).map(id => ({ id, provider: name })); + if (fresh) return applyConfigHintsToCachedModels(name, prov, fresh); // dedups Codex's frequent /v1/models polling within the TTL + const configured: CatalogModel[] = (prov.models ?? []).map(id => ({ + id, + provider: name, + ...catalogHintsFromProviderConfig(name, prov, id), + })); const { url, headers } = buildModelsRequest(prov, apiKey); try { const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) }); - if (!res.ok) return getStaleCached(name) ?? configured; - const json = await res.json() as { data?: { id: string; owned_by?: string }[] }; - const live = (json.data ?? []).map(m => ({ id: m.id, provider: name, owned_by: m.owned_by })); + if (!res.ok) { + const stale = getStaleCached(name); + return stale ? applyConfigHintsToCachedModels(name, prov, stale) : configured; + } + const json = await res.json() as { data?: ProviderModelsApiItem[] }; + const live = (json.data ?? []).map(m => ({ + id: m.id, + provider: name, + owned_by: m.owned_by, + ...catalogHintsFromProviderConfig(name, prov, m.id), + ...catalogHintsFromModelsApiItem(name, m), + })); const liveIds = new Set(live.map(m => m.id)); // Merge explicit config additions (e.g. a model not in the provider's /models, like a new endpoint). const merged = [...live, ...configured.filter(m => !liveIds.has(m.id))]; setCached(name, merged); return merged; } catch { - return getStaleCached(name) ?? configured; + const stale = getStaleCached(name); + return stale ? applyConfigHintsToCachedModels(name, prov, stale) : configured; } } @@ -325,12 +414,12 @@ export async function gatherRoutedModels(config: OcxConfig): Promise fetchProviderModels(name, prov, ttlMs)), ); - const all = augmentRoutedModelsWithJawcodeMetadata(lists.flat(), Object.keys(config.providers)); + const all = augmentRoutedModelsWithJawcodeMetadata(lists.flat(), Object.keys(config.providers), config.providers); all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider))); return all; } -export function augmentRoutedModelsWithJawcodeMetadata(models: CatalogModel[], providerNames: string[]): CatalogModel[] { +export function augmentRoutedModelsWithJawcodeMetadata(models: CatalogModel[], providerNames: string[], providers?: Record): CatalogModel[] { const out = [...models]; const seen = new Set(out.map(m => `${m.provider}/${m.id}`)); for (const provider of providerNames) { @@ -341,7 +430,7 @@ export function augmentRoutedModelsWithJawcodeMetadata(models: CatalogModel[], p const key = `${provider}/${meta.id}`; if (seen.has(key)) continue; seen.add(key); - out.push({ provider, id: meta.id, owned_by: provider }); + out.push({ provider, id: meta.id, owned_by: provider, ...(providers?.[provider] ? catalogHintsFromProviderConfig(provider, providers[provider], meta.id) : {}) }); } } return out; diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 5d1b67700..19011ba3f 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -121,24 +121,40 @@ export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | und * Only touches providers that are registry-managed AND still `authMode: "oauth"`, and only the * preset fields (never apiKey/baseUrl/user toggles). Persists + returns true when anything changed. */ +function cloneProviderField(value: unknown): unknown { + if (Array.isArray(value)) return [...value]; + if (value && typeof value === "object") return JSON.parse(JSON.stringify(value)); + return value; +} + +const OAUTH_RECONCILE_FIELDS: (keyof OcxProviderConfig)[] = [ + "models", + "noReasoningModels", + "noVisionModels", + "reasoningEfforts", + "modelReasoningEfforts", + "reasoningEffortMap", + "modelReasoningEffortMap", + "noTemperatureModels", + "noTopPModels", + "noPenaltyModels", + "autoToolChoiceOnlyModels", + "preserveReasoningContentModels", +]; + export function reconcileOAuthProviders(config: OcxConfig): boolean { let changed = false; for (const [name, prov] of Object.entries(config.providers)) { const def = OAUTH_PROVIDERS[name]; if (!def || prov.authMode !== "oauth") continue; const preset = def.providerConfig; - if (preset.models && JSON.stringify(prov.models) !== JSON.stringify(preset.models)) { - prov.models = [...preset.models]; - changed = true; - } - if (JSON.stringify(prov.noReasoningModels) !== JSON.stringify(preset.noReasoningModels)) { - if (preset.noReasoningModels) prov.noReasoningModels = [...preset.noReasoningModels]; - else delete prov.noReasoningModels; - changed = true; - } - if (JSON.stringify(prov.noVisionModels) !== JSON.stringify(preset.noVisionModels)) { - if (preset.noVisionModels) prov.noVisionModels = [...preset.noVisionModels]; - else delete prov.noVisionModels; + for (const field of OAUTH_RECONCILE_FIELDS) { + if (JSON.stringify(prov[field]) === JSON.stringify(preset[field])) continue; + if (preset[field] !== undefined) { + prov[field] = cloneProviderField(preset[field]) as never; + } else { + delete prov[field]; + } changed = true; } // Heal a defaultModel that no longer exists in the refreshed list (e.g. a deprecated snapshot). diff --git a/src/oauth/key-providers.ts b/src/oauth/key-providers.ts index fa63c3bd0..2bfd728c3 100644 --- a/src/oauth/key-providers.ts +++ b/src/oauth/key-providers.ts @@ -20,8 +20,17 @@ export interface KeyLoginProvider { * accept a reasoning param. Copied into the created provider config by `enrichProviderFromCatalog`, * so the classification actually gates the sidecars (matching is tolerant of an Ollama ":size" tag). */ + reasoningEfforts?: string[]; + modelReasoningEfforts?: Record; + reasoningEffortMap?: Record; + modelReasoningEffortMap?: Record>; noVisionModels?: string[]; noReasoningModels?: string[]; + noTemperatureModels?: string[]; + noTopPModels?: string[]; + noPenaltyModels?: string[]; + autoToolChoiceOnlyModels?: string[]; + preserveReasoningContentModels?: string[]; } export const KEY_LOGIN_PROVIDERS: Record = deriveKeyLoginMap(); @@ -37,8 +46,26 @@ export function enrichProviderFromCatalog(name: string, prov: OcxProviderConfig) if (!e) return; if (!prov.models && e.models) prov.models = [...e.models]; if (!prov.defaultModel && e.defaultModel) prov.defaultModel = e.defaultModel; + if (!prov.reasoningEfforts && e.reasoningEfforts) prov.reasoningEfforts = [...e.reasoningEfforts]; + if (!prov.modelReasoningEfforts && e.modelReasoningEfforts) prov.modelReasoningEfforts = cloneRecordOfArrays(e.modelReasoningEfforts); + if (!prov.reasoningEffortMap && e.reasoningEffortMap) prov.reasoningEffortMap = { ...e.reasoningEffortMap }; + if (!prov.modelReasoningEffortMap && e.modelReasoningEffortMap) prov.modelReasoningEffortMap = cloneNestedRecord(e.modelReasoningEffortMap); if (!prov.noVisionModels && e.noVisionModels) prov.noVisionModels = [...e.noVisionModels]; if (!prov.noReasoningModels && e.noReasoningModels) prov.noReasoningModels = [...e.noReasoningModels]; + if (!prov.noTemperatureModels && e.noTemperatureModels) prov.noTemperatureModels = [...e.noTemperatureModels]; + if (!prov.noTopPModels && e.noTopPModels) prov.noTopPModels = [...e.noTopPModels]; + if (!prov.noPenaltyModels && e.noPenaltyModels) prov.noPenaltyModels = [...e.noPenaltyModels]; + if (!prov.autoToolChoiceOnlyModels && e.autoToolChoiceOnlyModels) prov.autoToolChoiceOnlyModels = [...e.autoToolChoiceOnlyModels]; + if (!prov.preserveReasoningContentModels && e.preserveReasoningContentModels) prov.preserveReasoningContentModels = [...e.preserveReasoningContentModels]; +} + + +function cloneRecordOfArrays(input: Record): Record { + return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, [...value]])); +} + +function cloneNestedRecord(input: Record>): Record> { + return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, { ...value }])); } export function isKeyLoginProvider(name: string): boolean { diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 932cc04e1..69b175ef1 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -8,8 +8,17 @@ export interface DerivedKeyLoginProvider { dashboardUrl: string; models?: string[]; defaultModel?: string; + reasoningEfforts?: string[]; + modelReasoningEfforts?: Record; + reasoningEffortMap?: Record; + modelReasoningEffortMap?: Record>; noVisionModels?: string[]; noReasoningModels?: string[]; + noTemperatureModels?: string[]; + noTopPModels?: string[]; + noPenaltyModels?: string[]; + autoToolChoiceOnlyModels?: string[]; + preserveReasoningContentModels?: string[]; } export interface DerivedInitProvider { @@ -38,6 +47,14 @@ export function listRegistryEntries(): readonly ProviderRegistryEntry[] { return PROVIDER_REGISTRY; } +function cloneRecordOfArrays(input: Record): Record { + return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, [...value]])); +} + +function cloneNestedRecord(input: Record>): Record> { + return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, { ...value }])); +} + export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderConfig { return { adapter: entry.adapter, @@ -45,8 +62,17 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon authMode: entry.authKind === "local" ? undefined : entry.authKind, ...(entry.defaultModel ? { defaultModel: entry.defaultModel } : {}), ...(entry.models ? { models: [...entry.models] } : {}), + ...(entry.reasoningEfforts ? { reasoningEfforts: [...entry.reasoningEfforts] } : {}), + ...(entry.modelReasoningEfforts ? { modelReasoningEfforts: cloneRecordOfArrays(entry.modelReasoningEfforts) } : {}), + ...(entry.reasoningEffortMap ? { reasoningEffortMap: { ...entry.reasoningEffortMap } } : {}), + ...(entry.modelReasoningEffortMap ? { modelReasoningEffortMap: cloneNestedRecord(entry.modelReasoningEffortMap) } : {}), ...(entry.noVisionModels ? { noVisionModels: [...entry.noVisionModels] } : {}), ...(entry.noReasoningModels ? { noReasoningModels: [...entry.noReasoningModels] } : {}), + ...(entry.noTemperatureModels ? { noTemperatureModels: [...entry.noTemperatureModels] } : {}), + ...(entry.noTopPModels ? { noTopPModels: [...entry.noTopPModels] } : {}), + ...(entry.noPenaltyModels ? { noPenaltyModels: [...entry.noPenaltyModels] } : {}), + ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}), + ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}), }; } @@ -62,8 +88,17 @@ export function deriveKeyLoginMap(): Record { dashboardUrl: entry.dashboardUrl, ...(entry.models ? { models: [...entry.models] } : {}), ...(entry.defaultModel ? { defaultModel: entry.defaultModel } : {}), + ...(entry.reasoningEfforts ? { reasoningEfforts: [...entry.reasoningEfforts] } : {}), + ...(entry.modelReasoningEfforts ? { modelReasoningEfforts: cloneRecordOfArrays(entry.modelReasoningEfforts) } : {}), + ...(entry.reasoningEffortMap ? { reasoningEffortMap: { ...entry.reasoningEffortMap } } : {}), + ...(entry.modelReasoningEffortMap ? { modelReasoningEffortMap: cloneNestedRecord(entry.modelReasoningEffortMap) } : {}), ...(entry.noVisionModels ? { noVisionModels: [...entry.noVisionModels] } : {}), ...(entry.noReasoningModels ? { noReasoningModels: [...entry.noReasoningModels] } : {}), + ...(entry.noTemperatureModels ? { noTemperatureModels: [...entry.noTemperatureModels] } : {}), + ...(entry.noTopPModels ? { noTopPModels: [...entry.noTopPModels] } : {}), + ...(entry.noPenaltyModels ? { noPenaltyModels: [...entry.noPenaltyModels] } : {}), + ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}), + ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}), }; } return out; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index a674b7b15..54d1f9888 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -14,8 +14,17 @@ export interface ProviderRegistryEntry { dashboardUrl?: string; defaultModel?: string; models?: string[]; + reasoningEfforts?: string[]; + modelReasoningEfforts?: Record; + reasoningEffortMap?: Record; + modelReasoningEffortMap?: Record>; noVisionModels?: string[]; noReasoningModels?: string[]; + noTemperatureModels?: string[]; + noTopPModels?: string[]; + noPenaltyModels?: string[]; + autoToolChoiceOnlyModels?: string[]; + preserveReasoningContentModels?: string[]; oauthId?: string; jawcodeBundle?: string; extraMetadataAliases?: string[]; @@ -24,9 +33,32 @@ export interface ProviderRegistryEntry { export type ProviderConfigSeed = Pick< OcxProviderConfig, - "adapter" | "baseUrl" | "authMode" | "defaultModel" | "models" | "noVisionModels" | "noReasoningModels" + "adapter" | "baseUrl" | "authMode" | "defaultModel" | "models" + | "reasoningEfforts" | "modelReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap" + | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels" + | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" >; + +const ZAI_GLM_52_MODELS = ["glm-5.2", "glm-5.2[1m]"]; +const ZAI_GLM_52_REASONING_EFFORTS = ["low", "medium", "high", "xhigh"]; +const ZAI_GLM_52_REASONING_MAP: Record = { + none: "none", + minimal: "none", + low: "high", + medium: "high", + high: "high", + xhigh: "max", + max: "max", +}; +const KIMI_THINKING_MODELS = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5", "kimi-k2-0905-preview"]; +const KIMI_LOCKED_PARAMETER_MODELS = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5"]; +const NEURALWATT_REASONING_HISTORY_MODELS = [ + "glm-5.2", + "moonshotai/Kimi-K2.5", "kimi-k2.6", "kimi-k2.7-code", + "qwen3.5-397b", "qwen3.6-35b", +]; + export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ { id: "openai", @@ -75,11 +107,72 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ oauthId: "kimi", jawcodeBundle: "moonshot", note: "Log in with your Kimi account", - models: ["kimi-k2.6", "kimi-k2.5"], - defaultModel: "kimi-k2.6", + models: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5"], + defaultModel: "kimi-k2.7-code", + // Kimi thinking is controlled by Kimi's `thinking` extension, not OpenAI `reasoning_effort`. + noReasoningModels: KIMI_THINKING_MODELS, + modelReasoningEfforts: Object.fromEntries(KIMI_THINKING_MODELS.map(id => [id, []])), + noTemperatureModels: KIMI_LOCKED_PARAMETER_MODELS, + noTopPModels: KIMI_LOCKED_PARAMETER_MODELS, + noPenaltyModels: KIMI_LOCKED_PARAMETER_MODELS, + autoToolChoiceOnlyModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"], + preserveReasoningContentModels: KIMI_THINKING_MODELS, }, { id: "openai-apikey", label: "OpenAI (API key)", adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", authKind: "key", featured: true, dashboardUrl: "https://platform.openai.com/api-keys", defaultModel: "gpt-5.5" }, - { id: "opencode-go", label: "opencode go", adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", authKind: "key", featured: true, dashboardUrl: "https://opencode.ai/auth", defaultModel: "kimi-k2.6", jawcodeBundle: "opencode-go", note: "GLM, DeepSeek, Kimi, Qwen, MiMo…" }, + { + id: "opencode-go", label: "opencode go", adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", + authKind: "key", featured: true, dashboardUrl: "https://opencode.ai/auth", defaultModel: "kimi-k2.7-code", + jawcodeBundle: "opencode-go", note: "GLM, DeepSeek, Kimi, Qwen, MiMo…", + modelReasoningEfforts: { + "glm-5.2": ZAI_GLM_52_REASONING_EFFORTS, + "kimi-k2.7-code": [], + "kimi-k2.7-code-highspeed": [], + }, + modelReasoningEffortMap: { "glm-5.2": ZAI_GLM_52_REASONING_MAP }, + noReasoningModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"], + noTemperatureModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"], + noTopPModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"], + noPenaltyModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"], + autoToolChoiceOnlyModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"], + preserveReasoningContentModels: ["glm-5.2", "kimi-k2.7-code", "kimi-k2.7-code-highspeed"], + }, + { + id: "neuralwatt", + label: "Neuralwatt Cloud", + adapter: "openai-chat", + baseUrl: "https://api.neuralwatt.com/v1", + authKind: "key", + dashboardUrl: "https://portal.neuralwatt.com", + defaultModel: "glm-5.2", + models: [ + "glm-5.2", "glm-5.2-fast", + "moonshotai/Kimi-K2.5", "kimi-k2.5-fast", "kimi-k2.6", "kimi-k2.6-fast", + "kimi-k2.7-code", + "qwen3.5-397b", "qwen3.5-397b-fast", "qwen3.6-35b", "qwen3.6-35b-fast", + ], + // Neuralwatt's /v1/models metadata is authoritative; these static hints are the offline fallback. + modelReasoningEfforts: { + "glm-5.2": ZAI_GLM_52_REASONING_EFFORTS, + "glm-5.2-fast": [], + "moonshotai/Kimi-K2.5": [], + "kimi-k2.5-fast": [], + "kimi-k2.6": [], + "kimi-k2.6-fast": [], + "kimi-k2.7-code": [], + "qwen3.5-397b": ["low", "medium", "high"], + "qwen3.5-397b-fast": [], + "qwen3.6-35b": ["low", "medium", "high"], + "qwen3.6-35b-fast": [], + }, + modelReasoningEffortMap: { "glm-5.2": ZAI_GLM_52_REASONING_MAP }, + noReasoningModels: ["glm-5.2-fast", "kimi-k2.5-fast", "kimi-k2.6-fast", "qwen3.5-397b-fast", "qwen3.6-35b-fast"], + noVisionModels: ["glm-5.2", "glm-5.2-fast", "qwen3.5-397b", "qwen3.5-397b-fast"], + noTemperatureModels: ["kimi-k2.7-code"], + noTopPModels: ["kimi-k2.7-code"], + noPenaltyModels: ["kimi-k2.7-code"], + autoToolChoiceOnlyModels: ["kimi-k2.7-code"], + preserveReasoningContentModels: NEURALWATT_REASONING_HISTORY_MODELS, + }, { id: "openrouter", label: "OpenRouter", adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", authKind: "key", featured: true, dashboardUrl: "https://openrouter.ai/keys", jawcodeBundle: "openrouter" }, { id: "groq", label: "Groq", adapter: "openai-chat", baseUrl: "https://api.groq.com/openai/v1", authKind: "key", featured: true, dashboardUrl: "https://console.groq.com/keys" }, { id: "google", label: "Google Gemini", adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", authKind: "key", featured: true, dashboardUrl: "https://aistudio.google.com/apikey", defaultModel: "gemini-3-pro", jawcodeBundle: "google", extraMetadataAliases: ["gemini"] }, @@ -92,11 +185,30 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ { id: "together", label: "Together", baseUrl: "https://api.together.xyz/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://api.together.xyz/settings/api-keys" }, { id: "fireworks", label: "Fireworks", baseUrl: "https://api.fireworks.ai/inference/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://fireworks.ai/account/api-keys" }, { id: "firepass", label: "Fire Pass (Fireworks Kimi)", baseUrl: "https://api.fireworks.ai/inference/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://fireworks.ai/account/api-keys" }, - { id: "moonshot", label: "Moonshot (Kimi API)", baseUrl: "https://api.moonshot.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://platform.moonshot.ai/console/api-keys", defaultModel: "kimi-k2-0905-preview", jawcodeBundle: "moonshot" }, + { + id: "moonshot", label: "Moonshot (Kimi API)", baseUrl: "https://api.moonshot.ai/v1", adapter: "openai-chat", authKind: "key", + dashboardUrl: "https://platform.moonshot.ai/console/api-keys", defaultModel: "kimi-k2.7-code", jawcodeBundle: "moonshot", + models: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5", "kimi-k2-0905-preview"], + noReasoningModels: KIMI_THINKING_MODELS, + modelReasoningEfforts: Object.fromEntries(KIMI_THINKING_MODELS.map(id => [id, []])), + noTemperatureModels: KIMI_LOCKED_PARAMETER_MODELS, + noTopPModels: KIMI_LOCKED_PARAMETER_MODELS, + noPenaltyModels: KIMI_LOCKED_PARAMETER_MODELS, + autoToolChoiceOnlyModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"], + preserveReasoningContentModels: KIMI_THINKING_MODELS, + }, { id: "huggingface", label: "Hugging Face", baseUrl: "https://router.huggingface.co/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://huggingface.co/settings/tokens" }, { id: "nvidia", label: "NVIDIA NIM", baseUrl: "https://integrate.api.nvidia.com/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://build.nvidia.com" }, { id: "venice", label: "Venice", baseUrl: "https://api.venice.ai/api/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://venice.ai/settings/api" }, - { id: "zai", label: "Z.AI (GLM Coding)", baseUrl: "https://api.z.ai/api/coding/paas/v4", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://z.ai/manage-apikey/apikey-list", defaultModel: "glm-4.6" }, + { + id: "zai", label: "Z.AI (GLM Coding)", baseUrl: "https://api.z.ai/api/coding/paas/v4", adapter: "openai-chat", authKind: "key", + dashboardUrl: "https://z.ai/manage-apikey/apikey-list", defaultModel: "glm-5.2", + models: ["glm-5.2", "glm-5.2[1m]", "glm-5.1", "glm-5", "glm-4.6"], + noVisionModels: ZAI_GLM_52_MODELS, + modelReasoningEfforts: Object.fromEntries(ZAI_GLM_52_MODELS.map(id => [id, ZAI_GLM_52_REASONING_EFFORTS])), + modelReasoningEffortMap: Object.fromEntries(ZAI_GLM_52_MODELS.map(id => [id, ZAI_GLM_52_REASONING_MAP])), + preserveReasoningContentModels: ZAI_GLM_52_MODELS, + }, { id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://nano-gpt.com/api" }, { id: "synthetic", label: "Synthetic", baseUrl: "https://api.synthetic.new/openai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://synthetic.new" }, { id: "qwen-portal", label: "Qwen Portal", baseUrl: "https://portal.qwen.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://portal.qwen.ai" }, @@ -125,7 +237,18 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ { id: "mistral", label: "Mistral", baseUrl: "https://api.mistral.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://console.mistral.ai/api-keys", defaultModel: "codestral-latest" }, { id: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://platform.minimax.io", defaultModel: "MiniMax-M2.5", jawcodeBundle: "minimax", metadataModelIdNormalize: "case-insensitive" }, { id: "minimax-cn", label: "MiniMax (CN)", baseUrl: "https://api.minimaxi.com/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://platform.minimaxi.com", defaultModel: "MiniMax-M2.5", jawcodeBundle: "minimax", metadataModelIdNormalize: "case-insensitive" }, - { id: "kimi-code", label: "Kimi (coding)", baseUrl: "https://api.kimi.com/coding/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://platform.moonshot.cn/console/api-keys", defaultModel: "kimi-k2.5" }, + { + id: "kimi-code", label: "Kimi (coding)", baseUrl: "https://api.kimi.com/coding/v1", adapter: "openai-chat", authKind: "key", + dashboardUrl: "https://platform.moonshot.cn/console/api-keys", defaultModel: "kimi-k2.7-code", + models: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5"], + noReasoningModels: KIMI_THINKING_MODELS, + modelReasoningEfforts: Object.fromEntries(KIMI_THINKING_MODELS.map(id => [id, []])), + noTemperatureModels: KIMI_LOCKED_PARAMETER_MODELS, + noTopPModels: KIMI_LOCKED_PARAMETER_MODELS, + noPenaltyModels: KIMI_LOCKED_PARAMETER_MODELS, + autoToolChoiceOnlyModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"], + preserveReasoningContentModels: KIMI_THINKING_MODELS, + }, { id: "opencode-zen", label: "opencode zen", baseUrl: "https://opencode.ai/zen/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://opencode.ai/auth" }, { id: "vercel-ai-gateway", label: "Vercel AI Gateway", baseUrl: "https://ai-gateway.vercel.sh/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://vercel.com/dashboard" }, { id: "xiaomi", label: "Xiaomi MiMo", baseUrl: "https://api.xiaomimimo.com/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://xiaomimimo.com", defaultModel: "mimo-v2.5-pro" }, diff --git a/src/reasoning-effort.ts b/src/reasoning-effort.ts new file mode 100644 index 000000000..6ff594ccc --- /dev/null +++ b/src/reasoning-effort.ts @@ -0,0 +1,102 @@ +import type { OcxProviderConfig } from "./types"; +import { modelInList } from "./types"; + +export const CODEX_REASONING_LEVELS: { effort: string; description: string }[] = [ + { effort: "low", description: "Fast responses with lighter reasoning" }, + { effort: "medium", description: "Balances speed and reasoning depth" }, + { effort: "high", description: "Greater reasoning depth for complex problems" }, + { effort: "xhigh", description: "Extended reasoning for the hardest problems" }, +]; + +const CODEX_REASONING_ORDER = CODEX_REASONING_LEVELS.map(l => l.effort); +const CODEX_REASONING_SET = new Set(CODEX_REASONING_ORDER); + +export function modelRecordValue(record: Record | undefined, modelId: string): T | undefined { + if (!record) return undefined; + if (Object.prototype.hasOwnProperty.call(record, modelId)) return record[modelId]; + const colon = modelId.indexOf(":"); + if (colon > 0) { + const family = modelId.slice(0, colon); + if (Object.prototype.hasOwnProperty.call(record, family)) return record[family]; + } + const folded = modelId.toLowerCase(); + for (const [key, value] of Object.entries(record)) { + if (key.toLowerCase() === folded) return value; + } + return undefined; +} + +export function sanitizeCodexReasoningEfforts(efforts: readonly string[] | undefined): string[] | undefined { + if (efforts === undefined) return undefined; + const seen = new Set(); + const out: string[] = []; + for (const effort of efforts) { + if (!CODEX_REASONING_SET.has(effort) || seen.has(effort)) continue; + seen.add(effort); + out.push(effort); + } + return out.sort((a, b) => CODEX_REASONING_ORDER.indexOf(a) - CODEX_REASONING_ORDER.indexOf(b)); +} + +/** + * Provider/model configured reasoning levels for the Codex catalog. `undefined` means “no override”, + * while an empty array means “intentionally expose no effort control for this model”. + */ +export function configuredReasoningEfforts(provider: OcxProviderConfig, modelId: string): string[] | undefined { + if (modelInList(provider.noReasoningModels, modelId)) return []; + const modelEfforts = modelRecordValue(provider.modelReasoningEfforts, modelId); + if (modelEfforts !== undefined) return sanitizeCodexReasoningEfforts(modelEfforts) ?? []; + if (provider.reasoningEfforts !== undefined) return sanitizeCodexReasoningEfforts(provider.reasoningEfforts) ?? []; + return undefined; +} + +function requestToCodexEffort(requested: string): string | undefined { + if (requested === "none") return undefined; + if (requested === "minimal") return "low"; + if (requested === "max") return "xhigh"; + return CODEX_REASONING_SET.has(requested) ? requested : undefined; +} + +function clampToSupportedCodexEffort(requested: string, supported: readonly string[]): string | undefined { + if (supported.length === 0) return undefined; + const codex = requestToCodexEffort(requested); + if (!codex) return undefined; + if (supported.includes(codex)) return codex; + + const requestedRank = CODEX_REASONING_ORDER.indexOf(codex); + let best = supported[0]; + let bestRank = CODEX_REASONING_ORDER.indexOf(best); + for (const effort of supported) { + const rank = CODEX_REASONING_ORDER.indexOf(effort); + if (rank <= requestedRank && rank >= bestRank) { + best = effort; + bestRank = rank; + } + } + // If every supported tier is above the requested tier, choose the lowest supported tier. + return best; +} + +export function reasoningEffortMapFor(provider: OcxProviderConfig, modelId: string): Record | undefined { + return modelRecordValue(provider.modelReasoningEffortMap, modelId) ?? provider.reasoningEffortMap; +} + +/** + * Translate Codex's reasoning label into the provider's real wire value. The Codex catalog must only + * advertise labels Codex itself accepts (`low`/`medium`/`high`/`xhigh`), but some upstreams use + * different values (`max`) or a smaller subset (`low`/`medium`/`high`). + */ +export function mapReasoningEffort(provider: OcxProviderConfig, modelId: string, requested: string | undefined): string | undefined { + if (!requested) return undefined; + if (modelInList(provider.noReasoningModels, modelId)) return undefined; + + const wireMap = reasoningEffortMapFor(provider, modelId); + if (wireMap && Object.prototype.hasOwnProperty.call(wireMap, requested)) return wireMap[requested]; + + const supported = configuredReasoningEfforts(provider, modelId); + const codexEffort = supported !== undefined ? clampToSupportedCodexEffort(requested, supported) : requestToCodexEffort(requested); + if (!codexEffort) return undefined; + + if (wireMap && Object.prototype.hasOwnProperty.call(wireMap, codexEffort)) return wireMap[codexEffort]; + return codexEffort; +} diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 7b1fc7dd3..c809846f3 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -188,7 +188,7 @@ function findToolNameById(messages: OcxMessage[], callId: string): string { return ""; } -const REASONING_EFFORTS = new Set(["minimal", "low", "medium", "high", "xhigh", "max"]); +const REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max"]); export function parseRequest(body: unknown): OcxParsedRequest { const parsed = responsesRequestSchema.safeParse(body); diff --git a/src/types.ts b/src/types.ts index 3968e06b5..59663aa0c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -221,11 +221,33 @@ export interface OcxProviderConfig { * Only the openai-responses adapter implements "forward"; openai-chat uses its own key/token. */ authMode?: "key" | "forward" | "oauth"; + /** + * Provider-wide Codex-visible reasoning tiers for routed models. Use only Codex-supported labels + * here (`low`, `medium`, `high`, `xhigh`); translate to provider-specific wire values with + * `reasoningEffortMap` / `modelReasoningEffortMap` below. + */ + reasoningEfforts?: string[]; + /** Model-specific Codex-visible reasoning tiers. An empty array means “do not expose effort”. */ + modelReasoningEfforts?: Record; + /** Provider-wide mapping from Codex effort labels to upstream `reasoning_effort` values. */ + reasoningEffortMap?: Record; + /** Model-specific mapping from Codex effort labels to upstream `reasoning_effort` values. */ + modelReasoningEffortMap?: Record>; /** * Model ids that do NOT support a reasoning/thinking parameter. The openai-chat adapter drops * reasoning_effort for these even when Codex selects a reasoning level (e.g. xAI grok-build-0.1). */ noReasoningModels?: string[]; + /** Model ids that reject caller-specified temperature. */ + noTemperatureModels?: string[]; + /** Model ids that reject caller-specified top_p. */ + noTopPModels?: string[]; + /** Model ids that reject caller-specified presence/frequency penalty values. */ + noPenaltyModels?: string[]; + /** Model ids whose tool_choice only accepts `auto` or `none`; forced/named choices are downgraded. */ + autoToolChoiceOnlyModels?: string[]; + /** Model ids that expect prior assistant `reasoning_content` to be preserved in chat history. */ + preserveReasoningContentModels?: string[]; /** * Model ids that do NOT accept image inputs. The proxy gives them "eyes" via the vision sidecar: * attached images are described by a gpt vision model and replaced with text before the call. diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index 73d88de10..5a68bcdb2 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -25,7 +25,7 @@ function nativeTemplate(): Record { } const EXPECTED_KEY_PROVIDER_IDS = [ - "openai-apikey", "opencode-go", "openrouter", "groq", "google", "azure-openai", + "openai-apikey", "opencode-go", "neuralwatt", "openrouter", "groq", "google", "azure-openai", "deepseek", "cerebras", "together", "fireworks", "firepass", "moonshot", "huggingface", "nvidia", "venice", "zai", "nanogpt", "synthetic", "qwen-portal", "qianfan", "alibaba", "parallel", "zenmux", "litellm", "ollama-cloud", "mistral", diff --git a/tests/reasoning-effort.test.ts b/tests/reasoning-effort.test.ts new file mode 100644 index 000000000..9769c24fa --- /dev/null +++ b/tests/reasoning-effort.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, test } from "bun:test"; +import { buildCatalogEntries } from "../src/codex-catalog"; +import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; +import type { OcxParsedRequest, OcxProviderConfig } from "../src/types"; + +function nativeTemplate(): Record { + return { + slug: "gpt-5.5", + display_name: "gpt-5.5", + description: "Native GPT model", + priority: 1, + visibility: "list", + base_instructions: "You are Codex, a coding agent based on GPT-5.", + supported_reasoning_levels: [ + { effort: "low", description: "native low" }, + { effort: "medium", description: "native medium" }, + { effort: "high", description: "native high" }, + { effort: "xhigh", description: "native xhigh" }, + ], + }; +} + +function parsed(modelId: string, providerOptions: OcxParsedRequest["options"]): OcxParsedRequest { + return { + modelId, + context: { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, + stream: false, + options: providerOptions, + }; +} + +function buildBody(provider: OcxProviderConfig, modelId: string, options: OcxParsedRequest["options"]): Record { + const req = createOpenAIChatAdapter(provider).buildRequest(parsed(modelId, options)); + return JSON.parse(req.body as string) as Record; +} + +describe("provider-specific reasoning effort mapping", () => { + test("Codex catalog advertises only the efforts actually supported by a routed model", () => { + const entries = buildCatalogEntries(nativeTemplate(), [], [ + { provider: "neuralwatt", id: "glm-5.2", reasoningEfforts: ["low", "medium", "high", "xhigh"] }, + { provider: "moonshot", id: "kimi-k2.7-code", reasoningEfforts: [] }, + ]); + + const neuralwatt = entries.find(e => e.slug === "neuralwatt/glm-5.2"); + const kimi = entries.find(e => e.slug === "moonshot/kimi-k2.7-code"); + + expect((neuralwatt?.supported_reasoning_levels as { effort: string }[]).map(l => l.effort)).toEqual(["low", "medium", "high", "xhigh"]); + expect(neuralwatt?.default_reasoning_level).toBe("medium"); + expect(kimi?.supported_reasoning_levels).toEqual([]); + expect(kimi).not.toHaveProperty("default_reasoning_level"); + }); + + test("Z.AI GLM-5.2 maps Codex xhigh to the upstream max effort", () => { + const provider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + modelReasoningEfforts: { "glm-5.2": ["low", "medium", "high", "xhigh"] }, + modelReasoningEffortMap: { + "glm-5.2": { none: "none", minimal: "none", low: "high", medium: "high", high: "high", xhigh: "max", max: "max" }, + }, + }; + + expect(buildBody(provider, "glm-5.2", { reasoning: "xhigh" }).reasoning_effort).toBe("max"); + expect(buildBody(provider, "glm-5.2", { reasoning: "medium" }).reasoning_effort).toBe("high"); + }); + + test("low/medium/high-only models clamp stale xhigh requests to high", () => { + const provider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://api.neuralwatt.com/v1", + reasoningEfforts: ["low", "medium", "high"], + }; + + expect(buildBody(provider, "glm-5.2", { reasoning: "xhigh" }).reasoning_effort).toBe("high"); + }); + + test("Neuralwatt GLM-5.2 maps Codex xhigh to max and preserves reasoning history", () => { + const provider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://api.neuralwatt.com/v1", + modelReasoningEfforts: { "glm-5.2": ["low", "medium", "high", "xhigh"] }, + modelReasoningEffortMap: { + "glm-5.2": { none: "none", minimal: "none", low: "high", medium: "high", high: "high", xhigh: "max", max: "max" }, + }, + preserveReasoningContentModels: ["glm-5.2"], + }; + + const req = createOpenAIChatAdapter(provider).buildRequest({ + modelId: "glm-5.2", + context: { + messages: [ + { role: "user", content: "first", timestamp: 0 }, + { role: "assistant", timestamp: 1, content: [ + { type: "thinking", thinking: "prior reasoning" }, + { type: "text", text: "prior answer" }, + ] }, + { role: "user", content: "continue", timestamp: 2 }, + ], + }, + stream: false, + options: { reasoning: "xhigh" }, + }); + const body = JSON.parse(req.body as string) as { reasoning_effort?: string; messages: Record[] }; + + expect(body.reasoning_effort).toBe("max"); + expect(body.messages[1].reasoning_content).toBe("prior reasoning"); + }); + + test("Kimi K2.7 Code does not receive unsupported OpenAI reasoning/sampling controls", () => { + const provider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://api.moonshot.ai/v1", + noReasoningModels: ["kimi-k2.7-code"], + noTemperatureModels: ["kimi-k2.7-code"], + noTopPModels: ["kimi-k2.7-code"], + noPenaltyModels: ["kimi-k2.7-code"], + autoToolChoiceOnlyModels: ["kimi-k2.7-code"], + preserveReasoningContentModels: ["kimi-k2.7-code"], + }; + + const body = buildBody(provider, "kimi-k2.7-code", { + reasoning: "high", + temperature: 0.2, + topP: 0.7, + presencePenalty: 1, + frequencyPenalty: 1, + toolChoice: { name: "run_tests" }, + }); + + expect(body).not.toHaveProperty("reasoning_effort"); + expect(body).not.toHaveProperty("temperature"); + expect(body).not.toHaveProperty("top_p"); + expect(body).not.toHaveProperty("presence_penalty"); + expect(body).not.toHaveProperty("frequency_penalty"); + expect(body.tool_choice).toBe("auto"); + }); +});