diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index de4c7df150..e8e80dab1c 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -895,11 +895,20 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti enforceAnthropicImageLimits(messages); const tools = toolsToAnthropicFormat(parsed, toolNames); + // Codex never sends `max_output_tokens`, so the omitted-limit default decides how + // long a Claude answer may run. Honor the provider's configured output budget + // (`modelMaxOutputTokens` / `defaultMaxOutputTokens`) before falling back to the + // conservative 8192, which truncates long answers with stop_reason=max_tokens. + const configuredMaxOut = modelRecordValue(provider.modelMaxOutputTokens, parsed.modelId) + ?? provider.defaultMaxOutputTokens; + const omittedMaxTokens = typeof configuredMaxOut === "number" && configuredMaxOut > 0 + ? configuredMaxOut + : DEFAULT_MAX_TOKENS; const body: Record = { model: parsed.modelId, messages, stream: parsed.stream, - max_tokens: parsed.options.maxOutputTokens ?? DEFAULT_MAX_TOKENS, + max_tokens: parsed.options.maxOutputTokens ?? omittedMaxTokens, }; if (isOAuth) { // Claude OAuth (Pro/Max) requires the first system block to be the Claude Code identity. @@ -942,13 +951,13 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti // so effort=max (budget=32k) still leaves OUTPUT_HEADROOM tokens for visible output. body.max_tokens = explicitMaxOut !== undefined ? explicitMaxOut - : Math.min(ADAPTIVE_THINKING_CEILING, Math.max(DEFAULT_MAX_TOKENS, floor)); + : Math.max(omittedMaxTokens, Math.min(ADAPTIVE_THINKING_CEILING, Math.max(DEFAULT_MAX_TOKENS, floor))); } else { // Anthropic requires max_tokens > thinking.budget_tokens (max_tokens caps thinking + // visible output) and budget_tokens >= 1024. Codex sends the SAME value for both, which // 400s ("max_tokens must be greater than thinking.budget_tokens"). Size them so max_tokens // always exceeds the budget within a model-safe ceiling, reserving room for visible output. - const maxOut = parsed.options.maxOutputTokens ?? DEFAULT_MAX_TOKENS; + const maxOut = parsed.options.maxOutputTokens ?? omittedMaxTokens; const wantBudget = reasoningBudget(effectiveReasoning); const maxTokens = Math.min(REASONING_MAX_TOKENS_CEILING, Math.max(maxOut, wantBudget + OUTPUT_HEADROOM)); const budget = Math.max(MIN_THINKING_BUDGET, Math.min(wantBudget, maxTokens - OUTPUT_FLOOR)); diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index f48edcd03c..75d98d0241 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -32,7 +32,7 @@ import type { OcxConfig, OcxProviderConfig } from "../../types"; import { modelInList } from "../../types"; import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; import { isModelVisionSidecarConsumer } from "../../vision/eligibility"; -import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata"; +import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider, type ModelMetadata } from "../../generated/model-metadata"; import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; import { captureFastPolicyAuthority, @@ -862,6 +862,57 @@ interface ComboCatalogMemberFallback { readonly reasoningEfforts?: readonly string[]; } +/** + * Ladder advertised for a combo member whose vendor metadata says it reasons but + * carries no explicit ladder (Claude, Grok). Codex needs a non-empty ladder to show + * the effort control; the routed adapters clamp to the real upstream top rung. + */ +const ROUTED_COMBO_MEMBER_REASONING_EFFORTS: readonly string[] = ["low", "medium", "high", "xhigh", "max"]; + +/** + * Vendor-table lookup tolerant of point releases and date pins. Configured combo + * targets often name a variant the table does not carry (`claude-fable-5-1`, + * `claude-opus-4-5-20251101`); the base family row still describes its modality + * and reasoning capability, so fall back to it before giving up. + */ +function comboMemberVendorMetadata(provider: string, modelId: string): ModelMetadata | undefined { + const exact = getModelMetadataCaseInsensitive(provider, modelId); + if (exact) return exact; + let candidate = modelId.replace(/\[[^\]]*\]$/, ""); + while (true) { + const trimmed = candidate.replace(/-\d+$/, ""); + if (trimmed === candidate || !trimmed.includes("-")) return undefined; + const hit = getModelMetadataCaseInsensitive(provider, trimmed); + if (hit) return hit; + candidate = trimmed; + } +} + +/** + * Combo members are usually thin discovery rows (id + context window). Without a + * capability source the combo intersection collapses to text-only / no effort ladder, + * and the Codex app then refuses image attachments and hides the effort picker for + * every Claude combo. The generated vendor table knows both, so use it as the + * last-resort fallback when the caller supplied none. + */ +function vendorMetadataComboFallback(target: { provider: string; model: string }): ComboCatalogMemberFallback | undefined { + const metadataProvider = resolveMetadataProvider(target.provider); + const metadata = metadataProvider ? comboMemberVendorMetadata(metadataProvider, target.model) : undefined; + if (!metadata) return undefined; + return { + ...(typeof metadata.contextWindow === "number" && metadata.contextWindow > 0 + ? { contextWindow: metadata.contextWindow } + : {}), + ...(typeof metadata.maxTokens === "number" && metadata.maxTokens > 0 + ? { maxInputTokens: metadata.maxTokens } + : {}), + ...(Array.isArray(metadata.input) && metadata.input.length > 0 + ? { inputModalities: [...metadata.input] } + : {}), + ...(metadata.reasoning === true ? { reasoningEfforts: [...ROUTED_COMBO_MEMBER_REASONING_EFFORTS] } : {}), + }; +} + /** * Resolve a combo target to a catalog member for derivation. * Prefer discovery metadata; when the target is missing from the gather map or @@ -877,11 +928,12 @@ export function resolveComboCatalogMember( memberByKey: ReadonlyMap, providers: ReadonlyMap, contextCap?: number, - fallback?: ComboCatalogMemberFallback, + callerFallback?: ComboCatalogMemberFallback, metadataModelIdCaseFold?: boolean, ): CatalogModel | undefined { const existing = memberByKey.get(targetKey(target)); const prov = providers.get(target.provider); + const fallback = callerFallback ?? vendorMetadataComboFallback(target); // Disabled providers never contribute members — even a complete discovery row // is unusable for catalog derivation while the provider is off. if (prov?.disabled === true) return undefined; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 01b4a0fa1c..2c12048064 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -349,6 +349,10 @@ export type ProviderConfigSeed = Pick< // always on, per the official models overview and pricing page (platform.claude.com). const ANTHROPIC_MODELS = ["claude-fable-5-1", "claude-fable-5", "claude-sonnet-5", "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"]; const ANTHROPIC_MODEL_CONTEXT_WINDOWS: Record = { "claude-fable-5-1": 1_000_000, "claude-sonnet-5": 1_000_000, "claude-fable-5": 1_000_000, "claude-opus-5": 1_000_000, "claude-opus-4-8": 1_000_000, "claude-opus-4-7": 1_000_000, "claude-opus-4-6": 1_000_000, "claude-sonnet-4-6": 1_000_000, "claude-haiku-4-5": 200_000 }; +// Every current Claude family accepts at least 64k output tokens (Haiku 4.5 / Sonnet 4.x +// through Opus 5 and Fable 5). Anthropic caps max_tokens per model server-side, so a +// larger request never over-allocates; it only stops the 8192 truncation. +const ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS = 64_000; // 260814 GLM-5.3 is registered pre-emptively alongside 5.2 everywhere 5.2 appears. Z.AI's // devpack "How to Switch Models" page (docs.z.ai/devpack/latest-model) lists glm-5.3 and @@ -1314,6 +1318,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ note: "Log in with your Claude account", models: [...ANTHROPIC_MODELS], modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS }, + // Codex omits max_output_tokens; without a provider budget the Anthropic adapter + // falls back to 8192, which truncates long answers with stop_reason=max_tokens. + defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, defaultModel: "claude-sonnet-5", }, { @@ -1330,6 +1337,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ models: [...ANTHROPIC_MODELS], liveModels: true, modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS }, + defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, defaultModel: "claude-sonnet-5", }, { diff --git a/tests/anthropic-reasoning.test.ts b/tests/anthropic-reasoning.test.ts index c6ba14c239..a1e2eacc17 100644 --- a/tests/anthropic-reasoning.test.ts +++ b/tests/anthropic-reasoning.test.ts @@ -281,6 +281,20 @@ describe("anthropic extended-thinking gate", () => { expect(b.max_tokens as number).toBe(64000); }); + test("configured provider output budget replaces the 8192 default when the caller omits max_output_tokens", async () => { + const budgeted = { ...provider, defaultMaxOutputTokens: 64_000, modelMaxOutputTokens: { "claude-fable-5": 32_000 } }; + // No reasoning: the configured budget is the wire max_tokens. + expect((await bodyOf(parsed("none", {}, "claude-opus-5"), budgeted)).max_tokens).toBe(64_000); + expect((await bodyOf(parsed("none", {}, "claude-fable-5"), budgeted)).max_tokens).toBe(32_000); + // Adaptive thinking: the budget still wins over the headroom-derived ceiling. + expect((await bodyOf(parsed("max", {}, "claude-opus-5"), budgeted)).max_tokens).toBe(64_000); + // Budget thinking on an older family keeps max_tokens above the thinking budget. + const legacy = await bodyOf(parsed("high", {}, "claude-haiku-4-5"), budgeted); + expect(legacy.max_tokens as number).toBeGreaterThan((legacy.thinking as { budget_tokens: number }).budget_tokens); + // An explicit caller limit still wins over the configured budget. + expect((await bodyOf(parsed("none", { maxOutputTokens: 512 }, "claude-opus-5"), budgeted)).max_tokens).toBe(512); + }); + test.each([ ["high", 24_576], ["xhigh", 32_768], diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 4ae9926b30..8408fdbac1 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -1551,6 +1551,46 @@ describe("combo catalog capability intersection", () => { )).toBeUndefined(); }); + test("resolveComboCatalogMember restores vendor image and effort capabilities for thin Claude rows", () => { + const providers = new Map([["anthropic", { + adapter: "anthropic" as const, + baseUrl: "https://api.anthropic.com", + }]]); + // A discovery row that only carries id + window (the live Anthropic /models shape). + expect(resolveComboCatalogMember( + { provider: "anthropic", model: "claude-opus-5" }, + new Map([["anthropic/claude-opus-5", { provider: "anthropic", id: "claude-opus-5", contextWindow: 1_000_000 }]]), + providers, + )).toMatchObject({ + contextWindow: 1_000_000, + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], + }); + // Point-release ids fall back to their family row in the vendor table. + expect(resolveComboCatalogMember( + { provider: "anthropic", model: "claude-fable-5-1" }, + new Map([["anthropic/claude-fable-5-1", { provider: "anthropic", id: "claude-fable-5-1", contextWindow: 1_000_000 }]]), + providers, + )).toMatchObject({ + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], + }); + // An explicit caller fallback still wins over the vendor table. + expect(resolveComboCatalogMember( + { provider: "anthropic", model: "claude-opus-5" }, + new Map([["anthropic/claude-opus-5", { provider: "anthropic", id: "claude-opus-5", contextWindow: 1_000_000 }]]), + providers, + undefined, + { inputModalities: ["text"], reasoningEfforts: [] }, + )).toMatchObject({ inputModalities: ["text"], reasoningEfforts: [] }); + // Unknown ids keep their unknown ladder rather than inventing one. + expect(resolveComboCatalogMember( + { provider: "a", model: "ghost" }, + new Map(), + new Map([["a", { adapter: "openai-chat" as const, baseUrl: "https://a.example/v1" }]]), + )).not.toHaveProperty("reasoningEfforts"); + }); + test("still omits combos when synthesis cannot recover hard failures", async () => { const config: OcxConfig = { port: 10100,