From 8c845ec3f36505ed19da0ef8ee5e63b4af739c70 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 12 Nov 2026 13:47:39 -0500 Subject: [PATCH 1/3] fix: use supported model output allowances by default --- .../content/docs/reference/architecture.md | 20 ++- .../docs/reference/configuration/providers.md | 30 +++- src/adapters/anthropic.ts | 26 +-- src/adapters/command-code.ts | 3 +- src/adapters/google.ts | 4 +- src/adapters/openai-chat.ts | 6 +- src/adapters/openai-responses.ts | 6 + src/config.ts | 2 + src/output-budget.ts | 77 +++++++++ src/providers/registry.ts | 5 + src/router.ts | 5 +- src/server/claude-messages.ts | 13 ++ src/types.ts | 11 +- structure/02_config-and-codex-home.md | 19 ++- structure/04_transports-and-sidecars.md | 15 +- tests/anthropic-reasoning.test.ts | 67 +++++--- tests/claude-messages-endpoint.test.ts | 67 ++++++++ tests/output-budget-adapters.test.ts | 132 +++++++++++++++ tests/output-budget.test.ts | 150 ++++++++++++++++++ 19 files changed, 601 insertions(+), 57 deletions(-) create mode 100644 src/output-budget.ts create mode 100644 tests/output-budget-adapters.test.ts create mode 100644 tests/output-budget.test.ts diff --git a/docs-site/src/content/docs/reference/architecture.md b/docs-site/src/content/docs/reference/architecture.md index 1294ecfc3bd..9e1480375fa 100644 --- a/docs-site/src/content/docs/reference/architecture.md +++ b/docs-site/src/content/docs/reference/architecture.md @@ -118,14 +118,18 @@ Anthropic SSE comments and `ping` events count as upstream activity, including w thinking during context compaction. Proxy-generated keep-alives do not reset the stall deadline; an upstream connection that stops sending activity still times out. -For Opus 5.5 through the canonical Anthropic API, ordinary routed turns default to a -128,000-token total output allowance when the caller omits a limit. This uses the model's -[documented capacity for long coding turns](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/prompting-claude-opus-5-5#calibrate-effort) -and preserves the requested reasoning effort. Adaptive thinking and answer text share the -allowance; effort does not enforce a separate thinking budget. Explicit limits remain -authoritative, and other models and custom Anthropic-compatible endpoints retain their defaults. - -Routed context compaction for the same model and endpoint uses at most +Ordinary routed turns use known provider/model output allowances by default, including +requests with smaller caller-supplied caps. Set the global `outputTokenPolicy` to `request` +to honor explicit caps and configured fallback budgets. Output metadata is matched to the +provider destination before selecting an allowance. + +Native Claude Fable 5.2 and Opus 5.5 have 128,000-token output allowances. Adaptive thinking +and answer text share the allowance, while effort controls reasoning depth. Protocols +that expose no output-budget field leave generation limits to the upstream service. +See [output token allowances](/reference/configuration/providers/#output-token-allowances) +for configuration and coverage. + +Routed context compaction for Opus 5.5 through the canonical Anthropic API uses at most `medium` reasoning effort and defaults to a 64,000-token total output limit when the caller omits one. Adaptive thinking and summary text share that limit; effort does not reserve a fixed number of tokens for the summary. Explicit output limits and lower reasoning efforts diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index b08bf8814b6..e1c27dbe718 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -14,6 +14,7 @@ authenticated. | `disabledModels?` | `string[]` | — | Models hidden from Codex's catalog and `/v1/models`, but not blocked from direct proxy calls. A routed id is removed from listings. An account-qualified native id hides only that selector row; a bare native GPT id hides the bare row and every account-selector row for that model. The dashboard Models page exposes only routed and bare native rows; use this configuration field directly to hide one selector-qualified row. | | `providerContextCaps?` | `Record` | `{}` | Per-provider Codex-visible context caps. A cap only lowers a known context window. | | `contextCapValue?` | `number` | `350000` | Value used by the dashboard context-cap controls; changing it updates every enabled `providerContextCaps` entry. | +| `outputTokenPolicy?` | `"request" \| "model-max"` | `"model-max"` | Uses the known output allowance for ordinary requests, including requests with a smaller caller-supplied cap. Select `request` to honor caller caps and configured fallback budgets. | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by Codex Auth. Secrets live separately in `codex-accounts.json`. | | `pausedCodexAccountIds?` | `string[]` | `[]` | Accounts excluded from Pool selection until resumed, including the main `__main__` account when paused. | | `codexAccountNamespaces?` | `Record` | — | Optional map from an arbitrary public model selector to a stored Codex account target. Each selector whose target is present adds separate `/` rows to the Codex picker; each row uses only that account. With any selector active, bare native rows are hidden in the picker, but their ids remain routable and listed by raw `/v1/models` unless explicitly disabled. | @@ -38,6 +39,31 @@ configured pool ids or selector targets also cannot reuse a selector. Keep raw a emails private; the selector is the public name. See [Routing Configuration](/reference/configuration/routing/) for exact-selection behavior and precedence. +## Output token allowances + +Ordinary requests use the known output allowance for their selected provider and model by +default. This also applies when an existing configuration omits `outputTokenPolicy`. + +```json +{ + "outputTokenPolicy": "request" +} +``` + +With the default `model-max` policy, a smaller caller-supplied output cap is raised when CodexCommander has +matching output-limit metadata for the route. For example, a 32,768-token request for native +Claude Fable 5.2 can use its 128,000-token allowance. Thinking and visible output share that +allowance. + +The `request` setting shown above honors explicit caller caps. Requests that omit a cap can +use known model allowances after configured fallback budgets. Unknown capacities use the +request or adapter fallback. Adapters whose upstream protocol has no output-limit field use +the upstream's allowance. + +Context compaction keeps its maintenance budgets. Provider output ceilings and context +windows still apply, and reaching a ceiling produces an incomplete response. A larger +allowance can permit longer generation and higher usage. + ## Reserved OpenAI providers `openai` and `openai-apikey` are fixed reserved ids. `openai.codexAccountMode` is `"pool"` by default @@ -67,8 +93,8 @@ metadata, and Pro virtual ids rewrite to the base wire model with `reasoning.mod | `modelContextWindows?` | `Record` | Per-model context caps. These override `contextWindow` and never raise smaller live metadata. | | `modelInputModalities?` | `Record` | Per-model input hints such as `["text"]` or `["text", "image"]`. | | `modelMaxInputTokens?` | `Record` | Positive per-model max input limits used for catalog auto-compaction hints. | -| `defaultMaxOutputTokens?` | `number` | Provider-wide `openai-chat` fallback when the client omits `max_output_tokens`. | -| `modelMaxOutputTokens?` | `Record` | Positive per-model `openai-chat` fallback budgets; exact/pattern matches beat the provider default. | +| `defaultMaxOutputTokens?` | `number` | Provider-wide fallback for translated Anthropic, Chat Completions, Google, and Command Code requests when the client omits an output cap. | +| `modelMaxOutputTokens?` | `Record` | Positive per-model fallback budgets for translated requests. Exact/pattern matches beat the provider default. The `model-max` policy uses known model allowances ahead of these budgets. | | `chatCompletionTokenField?` | `"max_tokens" \| "max_completion_tokens"` | Chat Completions output-budget field. Defaults to `max_tokens`; canonical Kimi Coding Plan presets use `max_completion_tokens`. | | `headers?` | `Record` | Extra upstream headers. Authorization, cookies, API-key headers, embedded newlines, and invalid names are rejected. | | `openRouterRouting?` | `OpenRouterProviderRouting` | Default OpenRouter `order`, `only`, and `allowFallbacks` preferences; valid only for canonical OpenRouter with `openai-chat`. | diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 607a520ad3b..268db0d846b 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -1,6 +1,7 @@ import type { IncomingMeta, ProviderAdapter } from "./base"; import { debugDroppedFrame } from "../lib/debug"; import { isCanonicalAnthropicEndpoint } from "../lib/anthropic-endpoint"; +import { knownModelOutputTokenLimit, resolveOutputTokenBudget } from "../output-budget"; import type { AdapterEvent, CodexCommanderAssistantMessage, @@ -39,7 +40,7 @@ function toAnthropicContentPart(p: CodexCommanderContentPart): unknown { /** Default `max_tokens` when Codex omits `max_output_tokens`. */ const DEFAULT_MAX_TOKENS = 8192; -/** Safe ceiling for `max_tokens` (thinking + visible output) across current Claude 4.x models. */ +/** Compatibility fallback when a legacy thinking model has no verified output limit. */ const REASONING_MAX_TOKENS_CEILING = 32_000; /** Ordinary adaptive-thinking sizing: heuristic max effort allowance (32k) + 8192. * Adaptive effort is soft guidance, so this total does not reserve a hard visible-output portion. */ @@ -857,11 +858,8 @@ export function createAnthropicAdapter(provider: CodexCommanderProviderConfig, c enforceAnthropicImageLimits(messages); const tools = toolsToAnthropicFormat(parsed, toolNames); - // Adaptive effort has no fixed thinking reservation: the legacy effort heuristic - // can exhaust its shared total before an ordinary Opus 5.5 turn finishes. Use the - // documented long-turn allowance only for this known model and native endpoint; - // compatible gateways may have lower limits. Compaction keeps its smaller summary - // allowance and effort cap, while an explicit caller total always wins. + // Native Opus maintenance keeps its smaller summary allowance and effort cap. + // Ordinary turns resolve their total independently from reasoning effort. const version = claudeFamilyVersion(parsed.modelId); const nativeOpus55 = version?.family === "opus" && version.major === 5 && version.minor === 5 && isCanonicalAnthropicEndpoint(provider.baseUrl); @@ -871,8 +869,8 @@ export function createAnthropicAdapter(provider: CodexCommanderProviderConfig, c && requestedEffort !== "none" && requestedEffort !== "minimal" && requestedEffort !== "low" ? "medium" : requestedEffort; - const maxOutputTokens = parsed.options.maxOutputTokens - ?? (nativeOpus55 ? (opusCompaction ? OPUS_5_5_COMPACTION_MAX_TOKENS : OPUS_5_5_MAX_TOKENS) : undefined); + const maxOutputTokens = resolveOutputTokenBudget(provider, parsed, + nativeOpus55 ? (opusCompaction ? OPUS_5_5_COMPACTION_MAX_TOKENS : OPUS_5_5_MAX_TOKENS) : undefined); // Do not mutate parsed.options: requested-effort diagnostics describe the client. // Anthropic has no reasoningLog field for output_config.effort in the shared contract. @@ -927,12 +925,16 @@ export function createAnthropicAdapter(provider: CodexCommanderProviderConfig, c // 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 = maxOutputTokens ?? DEFAULT_MAX_TOKENS; const wantBudget = reasoningBudget(reasoningEffort); - 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)); + const knownLimit = knownModelOutputTokenLimit(provider, parsed.modelId); + const maxTokens = knownLimit !== undefined && maxOutputTokens !== undefined + ? maxOutputTokens + : Math.min(REASONING_MAX_TOKENS_CEILING, Math.max(maxOutputTokens ?? DEFAULT_MAX_TOKENS, wantBudget + OUTPUT_HEADROOM)); body.max_tokens = maxTokens; - body.thinking = { type: "enabled", budget_tokens: budget }; + if (maxTokens > MIN_THINKING_BUDGET) { + const budget = Math.max(MIN_THINKING_BUDGET, Math.min(wantBudget, maxTokens - OUTPUT_FLOOR)); + body.thinking = { type: "enabled", budget_tokens: budget }; + } } // Extended thinking disallows temperature != 1 and top_p — drop both or the API 400s. delete body.temperature; diff --git a/src/adapters/command-code.ts b/src/adapters/command-code.ts index c129eae45c8..761dda4c29e 100644 --- a/src/adapters/command-code.ts +++ b/src/adapters/command-code.ts @@ -1,4 +1,5 @@ import { dispatchHttpFetch, cleanupResponseDispatch, closeAdapterObservation, observeAdapterEvent, observeDispatch, responseDispatch } from "../usage/dispatch-http"; +import { resolveOutputTokenBudget } from "../output-budget"; import { randomUUID } from "node:crypto"; import { execFile as execFileCallback } from "node:child_process"; import { promisify } from "node:util"; @@ -356,7 +357,7 @@ export function createCommandCodeAdapter(provider: CodexCommanderProviderConfig) messages: wireMessages(parsed.context.messages), tools: wireTools(tools), system, - max_tokens: parsed.options.maxOutputTokens ?? provider.defaultMaxOutputTokens ?? 64_000, + max_tokens: resolveOutputTokenBudget(provider, parsed, provider.defaultMaxOutputTokens ?? 64_000), // The proprietary /alpha/generate endpoint is NDJSON-stream-only; the proxy converts // the buffered/streamed events to the client's requested shape (parsed.stream). stream: true, diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 443c87a4acf..432d4de4ca1 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -1,4 +1,5 @@ import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./base"; +import { resolveOutputTokenBudget } from "../output-budget"; import { debugDroppedFrame } from "../lib/debug"; import { createHash } from "node:crypto"; import { createImageBudget, materializeInlineImage, MAX_ENCODED_BYTES_PER_IMAGE, artifactHttpUrl } from "../images/artifacts"; @@ -335,7 +336,8 @@ export function createGoogleAdapter(provider: CodexCommanderProviderConfig): Pro if (toolConfig) body.toolConfig = toolConfig; const generationConfig: Record = {}; - if (parsed.options.maxOutputTokens) generationConfig.maxOutputTokens = parsed.options.maxOutputTokens; + const maxOutputTokens = resolveOutputTokenBudget(provider, parsed); + if (maxOutputTokens !== undefined) generationConfig.maxOutputTokens = maxOutputTokens; if (parsed.options.temperature !== undefined) generationConfig.temperature = parsed.options.temperature; if (parsed.options.topP !== undefined) generationConfig.topP = parsed.options.topP; if (parsed.options.stopSequences) generationConfig.stopSequences = parsed.options.stopSequences; diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index ae3e05a16db..53b0d79fa9a 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -2,6 +2,7 @@ import type { AdapterRequest, ProviderAdapter } from "./base"; import type { AdapterEvent, CodexCommanderAssistantMessage, CodexCommanderContentPart, CodexCommanderMessage, CodexCommanderParsedRequest, CodexCommanderProviderConfig, CodexCommanderTextContent, CodexCommanderThinkingContent, CodexCommanderToolCall, CodexCommanderUsage } from "../types"; import { isAllowedToolChoice, modelInList, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice } from "../types"; import { mapReasoningEffort, modelRecordValue } from "../reasoning-effort"; +import { resolveOutputTokenBudget } from "../output-budget"; import { debugProviderDiagnostic } from "../lib/debug"; import { isDebugEnabled } from "../lib/debug-settings"; import { isCyberPolicyCode } from "../lib/errors"; @@ -881,7 +882,8 @@ export function createOpenAIChatAdapter(provider: CodexCommanderProviderConfig): stream: parsed.stream, }; if (modelInList(provider.reasoningSplitModels, parsed.modelId)) body.reasoning_split = true; - const maxTokens = resolveMaxTokens(provider, parsed); + const requestedMaxTokens = resolveMaxTokens(provider, parsed); + const maxTokens = resolveOutputTokenBudget(provider, parsed, requestedMaxTokens); const openRouterRouting = resolveOpenRouterRouting(provider, parsed.modelId); if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting); if (tools) body.tools = tools; @@ -921,7 +923,7 @@ export function createOpenAIChatAdapter(provider: CodexCommanderProviderConfig): wireValue: reasoningEffort, }; } else if (modelInList(provider.thinkingBudgetModels, parsed.modelId)) { - const budget = thinkingBudgetForEffort(parsed, reasoningEffort, maxTokens); + const budget = thinkingBudgetForEffort(parsed, reasoningEffort, requestedMaxTokens); if (budget !== undefined) { body.thinking_budget = budget; reasoningLog = { diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index fcae14d1707..a70329abc7a 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1,4 +1,5 @@ import { isNativeResponsesProvider, NATIVE_RESPONSES_HEADERS } from "../responses/native-policy"; +import { knownModelOutputTokenLimit, resolveOutputTokenBudget } from "../output-budget"; import { createHash } from "node:crypto"; import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "./base"; import { namespacedToolName, type AdapterEvent, type CodexCommanderParsedRequest, type CodexCommanderProviderConfig, type CodexCommanderUsage } from "../types"; @@ -1235,6 +1236,11 @@ export function createResponsesPassthroughAdapter(provider: CodexCommanderProvid ); outBody = normalizeImageGenClientTools(outBody); } + if (!forward && provider._outputTokenPolicy === "model-max" + && knownModelOutputTokenLimit(provider, parsed.modelId) !== undefined + && parsed._compactionRequest !== true && isPlainObject(outBody)) { + outBody = { ...outBody, max_output_tokens: resolveOutputTokenBudget(provider, parsed) }; + } if (!native && (forward || parsed._previousResponseInputExpanded === true)) { outBody = repairOversizedReplayCallIds(outBody); } diff --git a/src/config.ts b/src/config.ts index f22ea05a16c..63e9ec96957 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1107,6 +1107,7 @@ const configSchema = z.object({ .optional(), providers: z.record(z.string(), providerConfigSchema), defaultProvider: z.string().min(1), + outputTokenPolicy: z.enum(["request", "model-max"]).optional(), claudeCode: claudeCodeSchema.optional(), clientIntegrations: clientIntegrationsSchema.optional(), subagentModels: subagentRosterSchema.optional(), @@ -2524,6 +2525,7 @@ export function getDefaultConfig(): CodexCommanderConfig { }, }, defaultProvider: "openai", + outputTokenPolicy: "model-max", subagentModels: DEFAULT_SUBAGENT_MODELS.map(model => ({ model })), multiAgentGuidanceEnabled: true, websockets: false, diff --git a/src/output-budget.ts b/src/output-budget.ts new file mode 100644 index 00000000000..40d87e2d124 --- /dev/null +++ b/src/output-budget.ts @@ -0,0 +1,77 @@ +import type { CodexCommanderConfig, CodexCommanderParsedRequest, CodexCommanderProviderConfig } from "./types"; +import { MODEL_ADAPTER_OVERRIDE_ALLOWED, pinnedWireAdapter } from "./types"; +import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive } from "./generated/jawcode-model-metadata"; +import { getProviderRegistryEntry, providerMatchesRegistryMetadataDestination } from "./providers/registry"; +import { isCanonicalAnthropicEndpoint } from "./lib/anthropic-endpoint"; +import { modelRecordValue } from "./reasoning-effort"; + +export type OutputTokenPolicy = "request" | "model-max"; + +/** Existing installations receive the same output policy as new configurations. */ +export function effectiveOutputTokenPolicy(config: Pick): OutputTokenPolicy { + return config.outputTokenPolicy ?? "model-max"; +} + +/** Resolve capacity only for the destination whose model metadata we know. */ +export function knownModelOutputTokenLimit(provider: CodexCommanderProviderConfig, modelId: string): number | undefined { + const registryId = provider._registryId + ?? (provider.adapter === "anthropic" && isCanonicalAnthropicEndpoint(provider.baseUrl) ? "anthropic" : undefined); + if (!registryId) return undefined; + const entry = getProviderRegistryEntry(registryId); + if (!entry) return undefined; + // Mixed-wire gateways choose their model-specific adapter after routing. Their + // destination's capacity metadata remains valid across those reviewed wire choices. + const declared = entry.modelWireDefaults?.[modelId.trim().toLowerCase()]; + const registryWire = typeof declared === "string" ? declared : declared?.wire; + const configuredWire = provider.modelAdapters?.[modelId]; + const knownWire = provider.adapter === entry.adapter + || pinnedWireAdapter(registryId, modelId) === provider.adapter + || (MODEL_ADAPTER_OVERRIDE_ALLOWED.has(provider.adapter) + && (registryWire === provider.adapter || configuredWire === provider.adapter)); + if (!knownWire || !providerMatchesRegistryMetadataDestination(registryId, { ...provider, adapter: entry.adapter })) return undefined; + const bundle = entry.jawcodeBundle ?? (entry.id === "openai-apikey" ? "openai" : entry.id); + const id = bundle === "anthropic" + ? modelId.replace(/^anthropic\//, "") + .replace(/\.(\d+)/g, "-$1") + .replace(/\[1m\]$/, "") + .replace(/-\d{8}$/, "") + : modelId; + const override = modelRecordValue(entry.modelOutputTokenLimits, id); + const metadata = entry.metadataModelIdNormalize === "case-insensitive" + ? getJawcodeModelMetadataCaseInsensitive(bundle, id) + : getJawcodeModelMetadata(bundle, id); + if (override === undefined && metadata?.contextWindow !== undefined + && metadata.contextWindow > 0 && metadata.maxTokens !== undefined + && metadata.maxTokens > metadata.contextWindow) return undefined; + const limit = override ?? metadata?.maxTokens; + // The OpenRouter bundle uses 8888 for undisclosed output limits. + if (bundle === "openrouter" && limit === 8888) return undefined; + return typeof limit === "number" && Number.isSafeInteger(limit) && limit > 0 ? limit : undefined; +} + +export function resolveOutputTokenBudget( + provider: CodexCommanderProviderConfig, + parsed: CodexCommanderParsedRequest, + fallback?: number, +): number | undefined { + const explicit = parsed.options.maxOutputTokens; + if (parsed._compactionRequest === true) return explicit ?? fallback; + const known = knownModelOutputTokenLimit(provider, parsed.modelId); + if (provider._outputTokenPolicy === "model-max" && known !== undefined) return known; + return explicit + ?? modelRecordValue(provider.modelMaxOutputTokens, parsed.modelId) + ?? provider.defaultMaxOutputTokens + ?? known + ?? fallback; +} + +/** Native Messages passthrough invokes this only after its canonical endpoint gate. */ +export function resolveNativeAnthropicOutputTokens( + policy: OutputTokenPolicy | undefined, + modelId: string, + explicit: number | undefined, + compaction: boolean, +): number | undefined { + if (policy !== "model-max" || compaction) return explicit; + return knownModelOutputTokenLimit({ adapter: "anthropic", baseUrl: "https://api.anthropic.com" }, modelId) ?? explicit; +} diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 5b8a9ede3e0..712bc933b05 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -204,6 +204,8 @@ export interface ProviderRegistryEntry { modelInputModalities?: Record; defaultMaxOutputTokens?: number; modelMaxOutputTokens?: Record; + /** Verified model output ceilings, separate from configured fallback request totals. */ + modelOutputTokenLimits?: Record; chatCompletionTokenField?: CodexCommanderProviderConfig["chatCompletionTokenField"]; reasoningEfforts?: string[]; modelReasoningEfforts?: Record; @@ -264,6 +266,7 @@ export type ProviderConfigSeed = Pick< // Model and context-window snapshot refreshed 2026-07-10. // Opus 5.5's 1M context verified 2026-10-23: https://platform.claude.com/docs/en/models/opus-5-5/overview. const ANTHROPIC_MODELS = ["claude-opus-5-5", "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_OUTPUT_TOKEN_LIMITS = { "claude-opus-5-5": 128_000, "claude-fable-5-2": 128_000 }; const ANTHROPIC_MODEL_CONTEXT_WINDOWS: Record = { "claude-opus-5-5": 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 }; const ZAI_GLM_52_MODELS = ["glm-5.2", "glm-5.2[1m]"]; @@ -1043,6 +1046,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ featured: true, oauthId: "anthropic", jawcodeBundle: "anthropic", + modelOutputTokenLimits: ANTHROPIC_MODEL_OUTPUT_TOKEN_LIMITS, note: "Log in with your Claude account", models: [...ANTHROPIC_MODELS], modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS }, @@ -1057,6 +1061,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ featured: true, dashboardUrl: "https://console.anthropic.com/settings/keys", jawcodeBundle: "anthropic", + modelOutputTokenLimits: ANTHROPIC_MODEL_OUTPUT_TOKEN_LIMITS, extraMetadataAliases: ["anthropic-key"], note: "Direct Anthropic API billing — no Claude subscription", models: [...ANTHROPIC_MODELS], diff --git a/src/router.ts b/src/router.ts index 77df6429d80..0fe10a8ec1e 100644 --- a/src/router.ts +++ b/src/router.ts @@ -10,6 +10,7 @@ import { import type { NormalizedComboConfig } from "./combos/types"; import { hasOwnProvider, resolveEnvValue } from "./config"; import { assertProviderDestinationAllowed } from "./lib/destination-policy"; +import { effectiveOutputTokenPolicy } from "./output-budget"; import { redactSecretString, redactUrlForLog } from "./lib/redact"; import { PROVIDER_REGISTRY, providerCodexAccountMode, providerMatchesRegistryTransport } from "./providers/registry"; import { @@ -253,7 +254,7 @@ function routedProviderConfig(providerName: string, provider: CodexCommanderProv const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === providerName); if (!registryEntry || !providerMatchesRegistryTransport(providerName, provider)) { assertProviderDestinationAllowed(providerName, provider); - return { ...provider, apiKey: usableResolvedApiKey(provider.apiKey) }; + return { ...provider, _registryId: undefined, apiKey: usableResolvedApiKey(provider.apiKey) }; } const resolvedApiKey = usableResolvedApiKey(provider.apiKey); const explicitKeyOverride = registryEntry.authKind === "oauth" @@ -310,6 +311,7 @@ function routedProviderConfig(providerName: string, provider: CodexCommanderProv return { ...provider, + _registryId: registryEntry.id, adapter: registryEntry.adapter, baseUrl, ...(provider.responsesPath === undefined && registryEntry.responsesPath !== undefined @@ -679,6 +681,7 @@ export function routeModel( policyEvidence?: PolicyRequestEvidence, ): RouteResult { const route = routeModelInternal(config, modelId, false, policyEvidence); + route.provider = { ...route.provider, _outputTokenPolicy: effectiveOutputTokenPolicy(config) }; // Policy routes carry a full evaluation trace already; never rebuild it. if (route.routeDecision) return route; const accountRef = route.codexAccountNamespace; diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index c749e829b1c..bd1f763fe9b 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -29,6 +29,8 @@ import { } from "../claude/outbound"; import { clearableDeadline, idleDeadline } from "../lib/abort"; import { estimateTokens } from "../lib/token-estimate"; +import { isCanonicalAnthropicEndpoint } from "../lib/anthropic-endpoint"; +import { effectiveOutputTokenPolicy, resolveNativeAnthropicOutputTokens } from "../output-budget"; import { NoEligiblePolicyCandidateError, routeModel } from "../router"; import { providerMatchesRegistryTransport } from "../providers/registry"; import { evidenceFromBody } from "../routing/request-evidence"; @@ -351,6 +353,17 @@ async function anthropicNativePassthrough( const base = (config.claudeCode?.anthropicBaseUrl ?? "https://api.anthropic.com").replace(/\/$/, ""); const search = new URL(req.url).search; + const outputTokenPolicy = effectiveOutputTokenPolicy(config); + if (pathname === "/v1/messages" && outputTokenPolicy === "model-max" + && isCanonicalAnthropicEndpoint(base)) { + const maxTokens = resolveNativeAnthropicOutputTokens( + outputTokenPolicy, + model, + typeof body.max_tokens === "number" ? body.max_tokens : undefined, + false, + ); + if (maxTokens !== undefined) body = { ...body, max_tokens: maxTokens }; + } // Native passthrough bypasses the anthropic adapter, so the generous image pipeline // (implementation contract) must run here: tier-normalize then // guard the already-Anthropic-wire messages before serialization. Applies to diff --git a/src/types.ts b/src/types.ts index a66929279f8..a95ae5abf7c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import type { KiroOAuthMetadata } from "./oauth/types"; +import type { OutputTokenPolicy } from "./output-budget"; export interface CodexCommanderParsedRequest { modelId: string; @@ -527,6 +528,8 @@ export interface SubagentRosterEntry { export interface CodexCommanderConfig { port: number; + /** Ordinary request output allowance. Compaction retains its bounded request budget. */ + outputTokenPolicy?: OutputTokenPolicy; /** Maximum usage-log bytes read for one management snapshot. */ managementUsageMaxReadBytes?: number; providers: Record; @@ -1017,6 +1020,9 @@ export interface RateLimitRetryPolicy { */ export interface CodexCommanderProviderConfig { adapter: string; + /** Request-local policy and canonical provider provenance, never persisted. */ + _outputTokenPolicy?: OutputTokenPolicy; + _registryId?: string; /** Cursor MCP compatibility bounds; positive integers when configured. */ mcpMaxTools?: number; mcpMaxSchemaBytes?: number; @@ -1120,8 +1126,9 @@ export interface CodexCommanderProviderConfig { /** Model-specific max input token limits. Values cap auto_compact_token_limit. */ modelMaxInputTokens?: Record; /** - * Provider-wide fallback for chat-completions `max_tokens` when the caller omits - * Responses `max_output_tokens`. Adapters still let an explicit request win. + * Provider-wide output fallback for translated Chat, Anthropic, Google, and Command + * Code requests when the caller omits a total. Model-max selects a verified model + * allowance when available. Maintenance compactions retain their budgets. */ defaultMaxOutputTokens?: number; /** Model-specific fallback output token budgets. Exact/model-pattern entries beat the provider default. */ diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index cd105777343..14ca85e928b 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -281,11 +281,20 @@ catalog/request tier metadata may use `priority`. Do not collapse these spelling ## Provider output defaults -`CodexCommanderProviderConfig.defaultMaxOutputTokens` and `modelMaxOutputTokens` are OpenAI Chat wire defaults, -not context-window metadata. They are applied only when a Responses request omits -`max_output_tokens`; an explicit request value wins, then a model-specific configured value, then -the provider default, then the adapter omits the output-budget field. `chatCompletionTokenField` -selects that field (`max_tokens` by default or `max_completion_tokens` for providers such as Kimi). +`CodexCommanderProviderConfig.defaultMaxOutputTokens` and `modelMaxOutputTokens` supply output +defaults for translated Anthropic, Chat Completions, Google, and Command Code requests. Under +`outputTokenPolicy: "request"`, an explicit request value wins, followed by a +model-specific configured budget, the provider default, known model capacity, and the adapter's +existing fallback. Known output capacities come from destination-matched registry metadata and +the generated model snapshot. They are independent of context-window caps. + +The top-level `outputTokenPolicy` defaults to `model-max` for existing and new configurations. +It selects a known model capacity ahead of explicit and configured budgets for ordinary requests. +Unknown capacities use request and adapter fallbacks. +Compaction keeps its maintenance budgets. Resolved provider objects carry this policy and +registry provenance only for the request, without modifying saved provider entries. +`chatCompletionTokenField` selects the Chat Completions field (`max_tokens` by default or +`max_completion_tokens` for providers such as Kimi). Both numeric fields must stay positive finite integers at disk-config and management validation boundaries. Registry entries may seed them through `providerConfigSeed`, key-login derivation, OAuth reconcile, diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index efa201bfc07..7b5794a86d5 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -33,11 +33,16 @@ within their route; neither route falls through to the other. See and before the `/v1/*` guard. Unknown `/v1/*` paths return JSON 404 errors instead of falling through to GUI static serving. -For verified Opus 5.5 on the canonical Anthropic API, the Anthropic adapter defaults ordinary -requests with omitted output limits to the documented 128,000-token total, independently of -effort. The requested ordinary effort and explicit output limits remain unchanged. Adaptive -thinking shares this total with answer text; the legacy effort-to-budget heuristic is not a -separate thinking allowance. Other models and custom destinations keep their existing defaults. +Ordinary requests use shared output-budget resolution across adapters that expose an output +limit. The top-level `outputTokenPolicy` defaults to `model-max` for existing and new +configurations. It uses a known model allowance even when the caller supplies a smaller cap. +The `request` policy honors explicit caps and configured fallbacks. Output metadata is scoped +to the provider destination. Unknown capacities use request and adapter fallbacks, while +protocols with no output-limit field use upstream-owned generation limits. + +Native Fable 5.2 and Opus 5.5 have documented 128,000-token output allowances. Adaptive thinking +shares the total with answer text. Effort controls reasoning depth independently of that +total. Compaction requests retain their maintenance budget policy under either setting. The Anthropic adapter applies a maintenance-only budget to fresh routed compaction requests for the verified Opus 5.5 model on the canonical Anthropic API: effort is capped at `medium` diff --git a/tests/anthropic-reasoning.test.ts b/tests/anthropic-reasoning.test.ts index 739cf190bec..40ead18d25d 100644 --- a/tests/anthropic-reasoning.test.ts +++ b/tests/anthropic-reasoning.test.ts @@ -25,6 +25,44 @@ async function bodyOf(p: CodexCommanderParsedRequest): Promise { + test.each([undefined, "medium", "high", "xhigh", "max"])("Fable 5.2 effort %s uses its full ordinary allowance when no cap is supplied", async effort => { + const request = parsed(effort, {}, "claude-fable-5-2"); + const body = await bodyOf(request); + expect(body.max_tokens).toBe(128_000); + expect(request.options.maxOutputTokens).toBeUndefined(); + }); + + test("model-max raises Fable's explicit limit while request policy preserves it", async () => { + const request = parsed("xhigh", { maxOutputTokens: 32_768 }, "claude-fable-5-2"); + const adapter = createAnthropicAdapter({ ...provider, _outputTokenPolicy: "model-max" }); + const body = JSON.parse((await adapter.buildRequest(request)).body); + expect(body.max_tokens).toBe(128_000); + expect((await bodyOf(request)).max_tokens).toBe(32_768); + expect(request.options.maxOutputTokens).toBe(32_768); + }); + + test("known legacy Haiku uses its full allowance and preserves explicit request totals", async () => { + const body = await bodyOf(parsed("xhigh", {}, "claude-haiku-4-5")); + expect(body.max_tokens).toBe(64_000); + expect(body.thinking).toEqual({ type: "enabled", budget_tokens: 24_576 }); + expect((await bodyOf(parsed("high", { maxOutputTokens: 8000 }, "claude-haiku-4-5"))).max_tokens).toBe(8000); + }); + + test.each([8000, 100_000])("unknown legacy models retain their compatibility heuristic for explicit %s", async explicit => { + const request = parsed("high", { maxOutputTokens: explicit }, "custom-model"); + for (const policy of ["request", "model-max"] as const) { + const adapter = createAnthropicAdapter({ ...provider, _outputTokenPolicy: policy }); + const body = JSON.parse((await adapter.buildRequest(request)).body); + expect(body.max_tokens).toBe(explicit === 8000 ? 24_576 : 32_000); + } + }); + + test("known legacy models keep tiny explicit totals without an invalid thinking reservation", async () => { + const body = await bodyOf(parsed("high", { maxOutputTokens: 64 }, "claude-haiku-4-5")); + expect(body.max_tokens).toBe(64); + expect(body.thinking).toBeUndefined(); + }); + test.each([ "claude-opus-5-5", "anthropic/claude-opus-5-5", @@ -120,12 +158,12 @@ describe("anthropic extended-thinking gate", () => { test.each([ ["claude-opus-5-4", 40_192], ["claude-opus-5-6", 40_192], ["claude-opus-6-5", 40_192], ["claude-opus-5-50", 40_192], - ["claude-sonnet-5-5", 40_192], ["claude-fable-5", 40_192], - ["custom-model", 32_000], ["claude-opus-4-6", 32_000], - ])("ordinary Opus 5.5 policy leaves other model %s unchanged", async (modelId, expectedMax) => { + ["claude-sonnet-5-5", 40_192], ["claude-fable-5", 128_000], + ["custom-model", 32_000], ["claude-opus-4-6", 128_000], + ])("ordinary model %s resolves a known allowance or compatibility fallback", async (modelId, expectedMax) => { const b = await bodyOf(parsed("max", {}, modelId)); expect(b.max_tokens).toBe(expectedMax); - if (expectedMax === 40_192) expect(b.output_config).toEqual({ effort: "max" }); + if (modelId !== "custom-model" && modelId !== "claude-opus-4-6") expect(b.output_config).toEqual({ effort: "max" }); else expect(b.thinking).toMatchObject({ type: "enabled" }); }); @@ -226,11 +264,10 @@ describe("anthropic extended-thinking gate", () => { expect(b.output_config).toEqual({ effort: "low" }); }); - test("adaptive-thinking model uses the ordinary max-effort total (issue #246)", async () => { + test("adaptive-thinking model uses its known output allowance (issue #246)", async () => { const b = await bodyOf(parsed("max", {}, "claude-fable-5")); - // Ordinary sizing uses a 32000-token allowance plus 8192, not a hard thinking - // budget: adaptive thinking can consume any portion of this shared total. - expect(b.max_tokens as number).toBe(40_192); + // Adaptive thinking and visible output share the known model allowance. + expect(b.max_tokens as number).toBe(128_000); expect(b.thinking).toEqual({ type: "adaptive" }); expect(b.output_config).toEqual({ effort: "max" }); }); @@ -253,15 +290,9 @@ describe("anthropic extended-thinking gate", () => { expect(b.max_tokens as number).toBe(64000); }); - test.each([ - ["medium", 16_384], // medium keeps its 8192 budget + headroom; never falls through to unknown. - ["high", 24_576], - ["xhigh", 32_768], - ["max", 40_192], - ["ultra", 40_192], // ultra mirrors the codex-rs boundary and clamps to max on the wire. - ])("adaptive-thinking %s effort uses its ordinary total-token default", async (effort, expected) => { + test.each(["medium", "high", "xhigh", "max", "ultra"])("adaptive-thinking %s effort retains the known model allowance", async effort => { const b = await bodyOf(parsed(effort, {}, "claude-fable-5")); - expect(b.max_tokens).toBe(expected); + expect(b.max_tokens).toBe(128_000); }); test("adaptive-thinking model clamps unsupported 'ultra' effort to 'max'", async () => { @@ -274,8 +305,8 @@ describe("anthropic extended-thinking gate", () => { test("adaptive-thinking model clamps unknown efforts to 'high'", async () => { const b = await bodyOf(parsed("ludicrous", {}, "claude-fable-5")); expect(b.output_config).toEqual({ effort: "high" }); - // Unknown clamps to high on the wire AND budgets like high (16384 + headroom). - expect(b.max_tokens).toBe(24_576); + // The effort mapping leaves the model output allowance independent. + expect(b.max_tokens).toBe(128_000); }); test("non-adaptive model maps ultra to the max thinking budget", async () => { diff --git a/tests/claude-messages-endpoint.test.ts b/tests/claude-messages-endpoint.test.ts index e4e6e4378b4..19781045234 100644 --- a/tests/claude-messages-endpoint.test.ts +++ b/tests/claude-messages-endpoint.test.ts @@ -13,6 +13,7 @@ import { estimateClaudeRequestTokens, fetchWithHeaderDeadline, handleClaudeMessages, + handleClaudeCountTokens, readBoundedPassthroughBody, resolvePassthroughBodyGuard, tapAnthropicSseForLog, @@ -93,6 +94,7 @@ function mockConfig(baseUrl: string, claudeCode?: CodexCommanderConfig["claudeCo port: 0, multiAgentGuidanceEnabled: true, defaultProvider: "mock", + outputTokenPolicy: "request", providers: { mock: { adapter: "openai-chat", baseUrl, apiKey: "k", allowPrivateNetwork: true }, }, @@ -277,6 +279,71 @@ test("native generated-agent passthrough preserves legacy thinking", async () => } }); +test("native Anthropic Messages applies model-max only to known model output allowances", async () => { + const captured: Array> = []; + globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { + captured.push(JSON.parse(String(init?.body)) as Record); + return Response.json({ + id: "msg_output_budget", + type: "message", + role: "assistant", + model: "claude-haiku-4-5", + content: [{ type: "text", text: "ok" }], + stop_reason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 }, + }); + }) as typeof globalThis.fetch; + for (const [policy, model, requested, expected, baseUrl] of [ + [undefined, "claude-haiku-4-5", 8_000, 64_000], + [undefined, "claude-haiku-4-5", undefined, 64_000], + [undefined, "claude-fable-5-2", 32_768, 128_000], + ["request", "claude-fable-5-2", 32_768, 32_768], + ["request", "claude-haiku-4-5", 8_000, 8_000], + ["request", "claude-haiku-4-5", undefined, undefined], + ["model-max", "claude-haiku-4-5", 8_000, 64_000], + ["model-max", "claude-haiku-4-5", undefined, 64_000], + ["model-max", "claude-unknown", 8_000, 8_000], + ["model-max", "claude-unknown", undefined, undefined], + ["model-max", "claude-opus-5-5", 8_000, 8_000, "https://gateway.example.test"], + ["model-max", "claude-opus-5-5", 8_000, 8_000, "https://api.anthropic.com.gateway.example.test"], + ] as const) { + const config = { + ...mockConfig("https://example.test/v1", baseUrl ? { anthropicBaseUrl: baseUrl } : undefined), + outputTokenPolicy: policy, + }; + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", + headers: { "content-type": "application/json", "x-api-key": "sk-ant-test" }, + body: JSON.stringify({ + model, + ...(requested !== undefined ? { max_tokens: requested } : {}), + thinking: { type: "enabled", budget_tokens: 2_048 }, + messages: [{ role: "user", content: "hello" }], + }), + }), config, { model: "unknown", provider: "unknown" }); + expect(response.status).toBe(200); + await response.text(); + expect(captured.at(-1)?.max_tokens).toBe(expected); + expect(captured.at(-1)?.thinking).toEqual({ type: "enabled", budget_tokens: 2_048 }); + } +}); + +test("native Anthropic token counting preserves its body with model-max enabled", async () => { + let captured: Record | undefined; + globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { + captured = JSON.parse(String(init?.body)) as Record; + return Response.json({ input_tokens: 4 }); + }) as typeof globalThis.fetch; + const response = await handleClaudeCountTokens(new Request("http://localhost/v1/messages/count_tokens", { + method: "POST", + headers: { "content-type": "application/json", "x-api-key": "sk-ant-test" }, + body: JSON.stringify({ model: "claude-haiku-4-5", messages: [{ role: "user", content: "hello" }] }), + }), { ...mockConfig("https://example.test/v1"), outputTokenPolicy: "model-max" }); + expect(response.status).toBe(200); + await response.text(); + expect(captured).not.toHaveProperty("max_tokens"); +}); + test("native Anthropic passthrough clears the header deadline before streaming the body", async () => { const encoder = new TextEncoder(); const upstream = Bun.serve({ diff --git a/tests/output-budget-adapters.test.ts b/tests/output-budget-adapters.test.ts new file mode 100644 index 00000000000..1a2bf21a23b --- /dev/null +++ b/tests/output-budget-adapters.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, test } from "bun:test"; +import { providerConfigSeed } from "../src/providers/derive"; +import { getProviderRegistryEntry } from "../src/providers/registry"; +import type { InboundWire } from "../src/providers/registry"; +import { routeModel } from "../src/router"; +import { resolveAdapter, resolveWireProtocolOverride } from "../src/server/adapter-resolve"; +import type { CodexCommanderConfig, CodexCommanderParsedRequest, CodexCommanderProviderConfig } from "../src/types"; +import { withTestTranslatorBudget } from "./helpers/translator-budget"; + +async function routedBody( + providerName: string, + modelId: string, + options: CodexCommanderParsedRequest["options"] = {}, + overrides: Partial = {}, + policy: CodexCommanderConfig["outputTokenPolicy"] = "model-max", + compaction = false, + inbound: InboundWire = "chat", +) { + const registry = getProviderRegistryEntry(providerName); + const provider = { + ...(registry ? providerConfigSeed(registry) : { adapter: "openai-chat", baseUrl: "https://example.test/v1" }), + apiKey: "test-output-budget-key", + ...overrides, + }; + const config: CodexCommanderConfig = { + port: 10100, + defaultProvider: providerName, + outputTokenPolicy: policy, + providers: { [providerName]: provider }, + }; + const route = routeModel(config, `${providerName}/${modelId}`); + route.provider = resolveWireProtocolOverride(providerName, route.modelId, route.provider, inbound); + const adapter = withTestTranslatorBudget(resolveAdapter(route.provider)); + const request = await adapter.buildRequest({ + modelId: route.modelId, + stream: false, + context: { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, + options, + _rawBody: { model: route.modelId, input: "hello", ...(options.maxOutputTokens !== undefined ? { max_output_tokens: options.maxOutputTokens } : {}) }, + ...(compaction ? { _compactionRequest: true } : {}), + }); + return JSON.parse(request.body) as Record; +} + +describe("routed output token policy on adapter wires", () => { + test("chat raises the wire allowance while preserving caller-sized thinking", async () => { + const body = await routedBody("deepseek", "deepseek-v4-flash", { maxOutputTokens: 8_000, reasoning: "high" }, { + thinkingBudgetModels: ["deepseek-v4-flash"], + reasoningEffortMap: { high: "high" }, + }); + expect(body.max_tokens).toBe(384_000); + expect(body.thinking_budget).toBe(6_000); + }); + + test("chat preserves explicit request limits under the default policy", async () => { + const body = await routedBody("deepseek", "deepseek-v4-flash", { maxOutputTokens: 8_000 }, {}, "request"); + expect(body.max_tokens).toBe(8_000); + }); + + test("chat uses the known model allowance when the request omits a limit", async () => { + const body = await routedBody("deepseek", "deepseek-v4-flash", {}, {}, "request"); + expect(body.max_tokens).toBe(384_000); + }); + + test("unknown custom chat models keep their existing configured fallback", async () => { + const body = await routedBody("custom", "unknown", { maxOutputTokens: 8_000 }, { defaultMaxOutputTokens: 16_000 }); + expect(body.max_tokens).toBe(8_000); + const omitted = await routedBody("custom", "unknown", {}, { defaultMaxOutputTokens: 16_000 }); + expect(omitted.max_tokens).toBe(16_000); + }); + + test("model-max never raises compaction requests", async () => { + const body = await routedBody("deepseek", "deepseek-v4-flash", { maxOutputTokens: 8_000 }, {}, "model-max", true); + expect(body.max_tokens).toBe(8_000); + }); + + test("AI Studio, Vertex, and Cloud Code Assist apply the same Gemini allowance", async () => { + for (const providerName of ["google", "google-vertex", "google-antigravity"]) { + const body = await routedBody(providerName, "gemini-3.5-flash", { maxOutputTokens: 8_000 }, { project: "test-output-budget-project" }); + const request = providerName === "google-antigravity" ? body.request : body; + expect(request.generationConfig.maxOutputTokens).toBe(65_536); + } + }); + + test("Google request policy preserves an explicit limit and fills an omitted allowance", async () => { + const explicit = await routedBody("google", "gemini-3.5-flash", { maxOutputTokens: 8_000 }, {}, "request"); + const omitted = await routedBody("google", "gemini-3.5-flash", {}, {}, "request"); + expect(explicit.generationConfig.maxOutputTokens).toBe(8_000); + expect(omitted.generationConfig.maxOutputTokens).toBe(65_536); + }); + + test("Command Code retains its 64k fallback for an unknown model", async () => { + const omitted = await routedBody("command-code", "unknown"); + const explicit = await routedBody("command-code", "unknown", { maxOutputTokens: 8_000 }); + expect(omitted.params.max_tokens).toBe(64_000); + expect(explicit.params.max_tokens).toBe(8_000); + }); + + test("API-key Responses raises only known model-max requests", async () => { + const known = await routedBody("openai-apikey", "gpt-5.4", { maxOutputTokens: 8_000 }); + const omitted = await routedBody("openai-apikey", "gpt-5.4"); + const omittedRequestPolicy = await routedBody("openai-apikey", "gpt-5.4", {}, {}, "request"); + const requestPolicy = await routedBody("openai-apikey", "gpt-5.4", { maxOutputTokens: 8_000 }, {}, "request"); + const unknown = await routedBody("openai-apikey", "unknown", { maxOutputTokens: 8_000 }); + const compaction = await routedBody("openai-apikey", "gpt-5.4", { maxOutputTokens: 8_000 }, {}, "model-max", true); + expect(known.max_output_tokens).toBe(128_000); + expect(omitted.max_output_tokens).toBe(128_000); + expect(omittedRequestPolicy).not.toHaveProperty("max_output_tokens"); + expect(requestPolicy.max_output_tokens).toBe(8_000); + expect(unknown.max_output_tokens).toBe(8_000); + expect(compaction.max_output_tokens).toBe(8_000); + }); + + test("a reviewed mixed-wire gateway keeps its known allowance on the Responses wire", async () => { + const body = await routedBody("deepseek", "deepseek-v4-flash", { maxOutputTokens: 8_000 }, { + modelAdapters: { "deepseek-v4-flash": "openai-responses" }, + }); + expect(body.max_output_tokens).toBe(384_000); + const registryDefault = await routedBody("deepseek", "deepseek-v4-flash", { maxOutputTokens: 8_000 }, {}, "model-max", false, "responses"); + expect(registryDefault.max_output_tokens).toBe(384_000); + }); + + test("forward requests never inject a policy output cap and preserve existing stripping", async () => { + const native = await routedBody("openai", "gpt-5.4"); + const custom = await routedBody("custom", "gpt-5.4", { maxOutputTokens: 8_000 }, { + adapter: "openai-responses", + authMode: "forward", + }); + expect(native).not.toHaveProperty("max_output_tokens"); + expect(custom).not.toHaveProperty("max_output_tokens"); + }); +}); diff --git a/tests/output-budget.test.ts b/tests/output-budget.test.ts new file mode 100644 index 00000000000..31e6a097d55 --- /dev/null +++ b/tests/output-budget.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from "bun:test"; +import { getDefaultConfig, validateConfigCandidate } from "../src/config"; +import { effectiveOutputTokenPolicy, knownModelOutputTokenLimit, resolveNativeAnthropicOutputTokens, resolveOutputTokenBudget } from "../src/output-budget"; +import { providerConfigSeed } from "../src/providers/derive"; +import { getProviderRegistryEntry } from "../src/providers/registry"; +import { routeModel } from "../src/router"; +import { resolveAdapter, resolveWireProtocolOverride } from "../src/server/adapter-resolve"; +import type { CodexCommanderConfig, CodexCommanderParsedRequest, CodexCommanderProviderConfig } from "../src/types"; +import { withTestTranslatorBudget } from "./helpers/translator-budget"; + +function provider(id = "anthropic-apikey"): CodexCommanderProviderConfig { + return { ...providerConfigSeed(getProviderRegistryEntry(id)!), _registryId: id }; +} + +function parsed(modelId = "claude-fable-5-2", maxOutputTokens?: number): CodexCommanderParsedRequest { + return { modelId, options: { maxOutputTokens }, context: { messages: [] }, stream: false }; +} + +describe("model output capacity", () => { + test.each([ + ["anthropic-apikey", "claude-fable-5-2", 128_000], + ["anthropic", "claude-opus-5-5", 128_000], + ["anthropic", "claude-haiku-4-5", 64_000], + ["openai-apikey", "gpt-5.4", 128_000], + ["deepseek", "deepseek-v4-flash", 384_000], + ["minimax", "minimax-m2.5", 131_072], + ["minimax", "MiniMax-M2.5", 131_072], + ["openrouter", "openai/gpt-oss-120b", 131_072], + ])("%s %s resolves the canonical output capacity", (id, modelId, expected) => { + expect(knownModelOutputTokenLimit(provider(id), modelId)).toBe(expected); + }); + + test("direct canonical Anthropic adapter resolves capacity without a route stamp", () => { + expect(knownModelOutputTokenLimit({ adapter: "anthropic", baseUrl: "https://api.anthropic.com/v1/messages" }, "claude-fable-5-2")).toBe(128_000); + }); + + test.each(["unknown", "meta-llama/llama-3-8b-instruct", "nex-agi/deepseek-v3.1-nex-n1", "openrouter/auto"])("unknown or inconsistent OpenRouter capacity remains unset for %s", modelId => { + expect(knownModelOutputTokenLimit(provider("openrouter"), modelId)).toBeUndefined(); + }); + + test("custom destinations do not borrow canonical model capacities", () => { + const custom = { ...provider(), baseUrl: "https://example.test/anthropic" }; + expect(knownModelOutputTokenLimit(custom, "claude-fable-5-2")).toBeUndefined(); + expect(resolveOutputTokenBudget({ ...custom, _outputTokenPolicy: "model-max" }, parsed(), 8192)).toBe(8192); + }); + + test.each([ + ["opencode-go", "minimax-m2.5", "anthropic"], + ["deepseek", "deepseek-v4-flash", "openai-responses"], + ])("%s %s retains capacity after resolving its %s wire", (id, modelId, adapter) => { + const initial = provider(id); + const resolved = resolveWireProtocolOverride(id, modelId, initial, "responses"); + expect(resolved.adapter).toBe(adapter); + expect(knownModelOutputTokenLimit(resolved, modelId)).toBe(knownModelOutputTokenLimit(initial, modelId)); + expect(knownModelOutputTokenLimit(resolved, modelId)).toBeGreaterThan(8192); + }); +}); + +describe("output token policy", () => { + test("model-max replaces lower and higher explicit caps with a known allowance without mutating options", () => { + for (const explicit of [32_768, 160_000]) { + const request = parsed("claude-fable-5-2", explicit); + expect(resolveOutputTokenBudget({ ...provider(), _outputTokenPolicy: "model-max" }, request)).toBe(128_000); + expect(request.options.maxOutputTokens).toBe(explicit); + } + }); + + test("request policy honors explicit and configured defaults before canonical capacity", () => { + const configured = { ...provider(), modelMaxOutputTokens: { "claude-fable-5-2": 16_000 }, defaultMaxOutputTokens: 24_000 }; + expect(resolveOutputTokenBudget(configured, parsed("claude-fable-5-2", 32_768))).toBe(32_768); + expect(resolveOutputTokenBudget(configured, parsed())).toBe(16_000); + expect(resolveOutputTokenBudget({ ...configured, modelMaxOutputTokens: undefined }, parsed())).toBe(24_000); + expect(resolveOutputTokenBudget(provider(), parsed(), 8192)).toBe(128_000); + }); + + test("unknown model-max models retain explicit, configured and adapter fallback behavior", () => { + const configured = { ...provider(), _outputTokenPolicy: "model-max" as const, defaultMaxOutputTokens: 24_000 }; + expect(resolveOutputTokenBudget(configured, parsed("unknown", 4096), 8192)).toBe(4096); + expect(resolveOutputTokenBudget(configured, parsed("unknown"), 8192)).toBe(24_000); + expect(resolveOutputTokenBudget(provider(), parsed("unknown"), 8192)).toBe(8192); + expect(resolveOutputTokenBudget(provider(), parsed("unknown"))).toBeUndefined(); + }); + + test("compaction ignores model-max and configured model output defaults", () => { + const configured = { ...provider(), _outputTokenPolicy: "model-max" as const, defaultMaxOutputTokens: 128_000 }; + const request = { ...parsed(), _compactionRequest: true }; + expect(resolveOutputTokenBudget(configured, request, 8192)).toBe(8192); + expect(resolveOutputTokenBudget(configured, { ...request, options: { maxOutputTokens: 4096 } }, 8192)).toBe(4096); + }); + + test("native Anthropic passthrough changes only known ordinary model-max totals", () => { + expect(resolveNativeAnthropicOutputTokens("model-max", "claude-fable-5-2", 32_768, false)).toBe(128_000); + expect(resolveNativeAnthropicOutputTokens("model-max", "claude-haiku-4-5", undefined, false)).toBe(64_000); + expect(resolveNativeAnthropicOutputTokens("request", "claude-fable-5-2", 32_768, false)).toBe(32_768); + expect(resolveNativeAnthropicOutputTokens("model-max", "claude-fable-5-2", 8192, true)).toBe(8192); + expect(resolveNativeAnthropicOutputTokens("model-max", "unknown", 4096, false)).toBe(4096); + }); + + test("config validates the optional global enum and keeps registry capacity facts out of saved seeds", () => { + const defaults = getDefaultConfig(); + expect(defaults.outputTokenPolicy).toBe("model-max"); + expect(effectiveOutputTokenPolicy({})).toBe("model-max"); + expect(effectiveOutputTokenPolicy({ outputTokenPolicy: "request" })).toBe("request"); + expect(validateConfigCandidate(defaults).ok).toBe(true); + expect(validateConfigCandidate({ ...defaults, outputTokenPolicy: "model-max" }).ok).toBe(true); + expect(validateConfigCandidate({ ...defaults, outputTokenPolicy: "request" }).ok).toBe(true); + expect(validateConfigCandidate({ ...defaults, outputTokenPolicy: "unlimited" }).ok).toBe(false); + const seed = providerConfigSeed(getProviderRegistryEntry("anthropic-apikey")!); + expect(seed).not.toHaveProperty("modelOutputTokenLimits"); + expect(seed).not.toHaveProperty("_registryId"); + }); + + test.each(["anthropic-apikey/claude-fable-5-2", "claude-fable-5-2", "unknown", "combo/one", "policy/one"])("routing stamps the global policy for %s without changing config", modelId => { + const configured = provider(); + delete configured._registryId; + const config: CodexCommanderConfig = { + port: 10100, + defaultProvider: "anthropic-apikey", + outputTokenPolicy: "model-max", + providers: { "anthropic-apikey": configured }, + combos: { one: { targets: [{ provider: "anthropic-apikey", model: "claude-fable-5-2" }] } }, + routingProfiles: { one: { candidates: [{ provider: "anthropic-apikey", model: "claude-fable-5-2" }] } }, + }; + const before = JSON.stringify(config); + const route = routeModel(config, modelId); + expect(route.provider._outputTokenPolicy).toBe("model-max"); + expect(route.provider._registryId).toBe("anthropic-apikey"); + expect(JSON.stringify(config)).toBe(before); + expect(routeModel({ ...config, outputTokenPolicy: undefined }, modelId).provider._outputTokenPolicy).toBe("model-max"); + expect(routeModel({ ...config, outputTokenPolicy: "request" }, modelId).provider._outputTokenPolicy).toBe("request"); + }); + + test.each([ + [undefined, 128_000], + ["request", 32_768], + ] as const)("routed Anthropic requests apply global policy %s through the adapter entry point", async (policy, expected) => { + const config: CodexCommanderConfig = { + port: 10100, + defaultProvider: "anthropic-apikey", + providers: { "anthropic-apikey": { ...provider(), apiKey: "test-output-policy-key" } }, + ...(policy ? { outputTokenPolicy: policy } : {}), + }; + const route = routeModel(config, "anthropic-apikey/claude-fable-5-2"); + const request = parsed(route.modelId, 32_768); + const adapter = withTestTranslatorBudget(resolveAdapter(route.provider)); + const wire = await adapter.buildRequest(request); + expect(JSON.parse(wire.body).max_tokens).toBe(expected); + expect(request.options.maxOutputTokens).toBe(32_768); + }); +}); From af24940fad6311b2dc0f1f7ea05150cf22b993f9 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 12 Nov 2026 13:47:39 -0500 Subject: [PATCH 2/3] chore(release): prepare 0.1.27 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1926acc44cc..67af0427584 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codexcommander", - "version": "0.1.26", + "version": "0.1.27", "private": true, "description": "CodexCommander — universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", From a61da44ef6af6d62167a9f8b2835801b71baddfc Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 12 Nov 2026 13:57:37 -0500 Subject: [PATCH 3/3] test: cover caller-cap opt-out and automatic output allowances --- tests/responses-compaction-routing.test.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/tests/responses-compaction-routing.test.ts b/tests/responses-compaction-routing.test.ts index 290331f032b..1d8f7a26ae9 100644 --- a/tests/responses-compaction-routing.test.ts +++ b/tests/responses-compaction-routing.test.ts @@ -207,10 +207,11 @@ describe("Opus 5.5 output policy through the real Anthropic adapter", () => { test.each([ [false, 4096], [true, 4096], [false, 40_192], [true, 40_192], - ])("ordinary caller-capped workload stays incomplete without retry (stream=%s, total=%s)", async (stream, maxOutputTokens) => { + ])("request policy keeps ordinary caller-capped workload incomplete without retry (stream=%s, total=%s)", async (stream, maxOutputTokens) => { const sent = stubAnthropic("end_turn", 60_000); const logCtx: RequestLogContext = { model: "", provider: "" }; - const response = await handleResponses(compactionRequest(ordinaryBody({ stream, max_output_tokens: maxOutputTokens })), config, logCtx); + const response = await handleResponses(compactionRequest(ordinaryBody({ stream, max_output_tokens: maxOutputTokens })), + { ...config, outputTokenPolicy: "request" }, logCtx); expect(response.status).toBe(200); const result = stream ? terminal(await response.text()) : await response.json() as { status: string; incomplete_details?: { reason: string }; output: Array<{ type: string }> }; expect(result.status).toBe("incomplete"); @@ -221,6 +222,20 @@ describe("Opus 5.5 output policy through the real Anthropic adapter", () => { expect(logCtx.usage?.outputTokens).toBe(maxOutputTokens); }); + test.each([false, true])("default model-max completes an ordinary workload with a smaller caller total without retry (stream=%s)", async stream => { + const sent = stubAnthropic("end_turn", 60_000); + const logCtx: RequestLogContext = { model: "", provider: "" }; + const response = await handleResponses(compactionRequest(ordinaryBody({ stream, max_output_tokens: 32_768 })), config, logCtx); + expect(response.status).toBe(200); + const result = stream ? terminal(await response.text()) : await response.json() as { status: string; output: Array<{ type: string }> }; + expect(result.status).toBe("completed"); + expect(result.output.some(item => item.type === "message")).toBe(true); + expect(result.output.some(item => item.type === "compaction")).toBe(false); + expect(sent).toEqual([{ model: "claude-opus-5-5", maxTokens: 128_000, thinking: "adaptive", + effort: "max", stream, tools: true }]); + expect(logCtx.usage?.outputTokens).toBe(60_000); + }); + test.each(["v1", "v2"])("%s compaction gets one complete summary at medium with a bounded total", async version => { const sent = stubAnthropic(); const logCtx: RequestLogContext = { model: "", provider: "" };