From f05c23e06fa14534a983472b22e7477e98e135e7 Mon Sep 17 00:00:00 2001 From: full999 Date: Thu, 3 Sep 2026 14:47:19 +0900 Subject: [PATCH] fix(catalog,anthropic): keep Claude combo image/effort capabilities and honor provider output budget Two defects surfaced together when Claude models are exposed to Codex through failover combos: 1. Combo members are usually thin discovery rows (id + context window). With no capability source the modality/effort intersection collapsed to text-only and an empty ladder, so the Codex app refused image attachments ("remove the image or switch models") and hid the effort picker for every Claude combo. resolveComboCatalogMember now falls back to the generated vendor metadata table for input modalities and reasoning capability, and point-release ids (claude-fable-5-1, date-pinned ids) resolve to their family row. 2. Codex never sends max_output_tokens, so the Anthropic adapter always used max_tokens=8192. Long answers stopped with stop_reason=max_tokens and Codex retried the identical turn up to five times. The adapter now honors the provider's modelMaxOutputTokens / defaultMaxOutputTokens for omitted limits, and the anthropic / anthropic-apikey registry entries default to 64000. Explicit caller limits still win unchanged. --- src/adapters/anthropic.ts | 15 ++++++-- src/codex/catalog/provider-fetch.ts | 56 +++++++++++++++++++++++++++-- src/providers/registry.ts | 8 +++++ tests/anthropic-reasoning.test.ts | 14 ++++++++ tests/codex-catalog.test.ts | 40 +++++++++++++++++++++ 5 files changed, 128 insertions(+), 5 deletions(-) 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,