From ba2e3fc314f153b62b5cacab6550fc5b17f3fded Mon Sep 17 00:00:00 2001 From: pavelhov Date: Sat, 24 Oct 2026 00:20:33 -0400 Subject: [PATCH 1/2] fix: restore native Opus limits and thinking continuity --- .../content/docs/reference/architecture.md | 24 +- src/adapters/anthropic.ts | 75 ++- src/bridge.ts | 17 +- src/codex/catalog/provider-fetch.ts | 27 +- src/lib/anthropic-endpoint.ts | 23 + src/oauth/index.ts | 4 +- src/providers/derive.ts | 15 +- src/providers/registry.ts | 19 +- src/responses/parser.ts | 25 +- src/server/responses/terminal-guard.ts | 37 +- src/types.ts | 5 +- structure/03_catalog-and-subagents.md | 7 + structure/04_transports-and-sidecars.md | 19 +- tests/anthropic-reasoning.test.ts | 98 +++- tests/anthropic-thinking-signature.test.ts | 442 +++++++++++++++++- tests/codex-catalog.test.ts | 180 ++++++- .../provider-model-discovery-contract.test.ts | 116 ++++- tests/responses-compaction-routing.test.ts | 51 +- tests/terminal-guard.test.ts | 122 +++++ 19 files changed, 1234 insertions(+), 72 deletions(-) create mode 100644 src/lib/anthropic-endpoint.ts diff --git a/docs-site/src/content/docs/reference/architecture.md b/docs-site/src/content/docs/reference/architecture.md index ad9316b3e34..1294ecfc3bd 100644 --- a/docs-site/src/content/docs/reference/architecture.md +++ b/docs-site/src/content/docs/reference/architecture.md @@ -102,6 +102,12 @@ understands: | `done` | `response.completed` (with usage) | | `error` | `response.failed` (with `last_error`) | +Anthropic thinking signatures and redacted blocks survive tool-result continuation even when +their thinking text is empty. Each signed block keeps its own text/signature pairing in both +JSON and streaming responses. Opaque reasoning from other providers is not converted into +invented Anthropic thinking text. Automatic terminal-guard continuations preserve the same +block order. An unfinished thinking block fails instead of completing with unusable replay state. + The bridge also runs a **heartbeat keep-alive** (RC3): during upstream silence, it emits a parser-ignored `response.heartbeat` SSE event every 2 seconds to re-arm Codex's idle timer. The default **stall deadline** is 300 seconds (`stallTimeoutSec`); reaching it aborts the upstream and emits @@ -112,12 +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, routed context compaction uses at most +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 `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 -are preserved. This maintenance policy applies to both compaction endpoints; ordinary work -keeps its requested effort and existing defaults. A truncated summary still fails instead +are preserved. This maintenance policy applies to both compaction endpoints. A truncated summary still fails instead of replacing the conversation history, and the proxy does not automatically retry it. The older compact endpoint streams routed Anthropic generation internally, then returns one JSON response to its caller. This lets upstream pings keep a long summary alive without @@ -246,6 +258,12 @@ history, while `responses/parser.ts` and `bridge.ts` handle remote compaction v2 [subagent models](/guides/codex-integration/#the-subagent-picker) first, filters `disabledModels`, and can fully restore the pristine catalog from a one-time backup. +Canonical Anthropic model discovery recognizes `max_input_tokens` as the input context window. +Native Opus 5.5 also has a known 1,000,000-token context fallback when live discovery is unavailable; +the default Codex compaction threshold is 900,000. Explicit context limits remain authoritative, +and custom destinations do not inherit native Anthropic context assumptions. Existing Codex +processes must reload the refreshed catalog before using its larger window. + ## Reasoning effort `reasoning-effort.ts` translates Codex's reasoning labels into each provider's wire values. The diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 5514b8f79fc..607a520ad3b 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -1,5 +1,6 @@ import type { IncomingMeta, ProviderAdapter } from "./base"; import { debugDroppedFrame } from "../lib/debug"; +import { isCanonicalAnthropicEndpoint } from "../lib/anthropic-endpoint"; import type { AdapterEvent, CodexCommanderAssistantMessage, @@ -43,6 +44,8 @@ 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. */ const ADAPTIVE_THINKING_CEILING = 40_192; +/** Documented total-token allowance for long native Opus 5.5 agent turns. */ +const OPUS_5_5_MAX_TOKENS = 128_000; /** Bounded thinking + summary total for native Opus 5.5 maintenance compactions. */ const OPUS_5_5_COMPACTION_MAX_TOKENS = 64_000; /** Anthropic's documented minimum `thinking.budget_tokens`. */ @@ -854,20 +857,22 @@ export function createAnthropicAdapter(provider: CodexCommanderProviderConfig, c enforceAnthropicImageLimits(messages); const tools = toolsToAnthropicFormat(parsed, toolNames); - // Compaction is a handoff summary, not another maximum-effort agent turn. Opus 5.5 - // always thinks; adaptive effort has no fixed thinking budget and can consume the - // ordinary 40192-token total before finishing its summary. Keep this policy local - // to the known model and native endpoint: compatible gateways may have lower limits. - const version = parsed._compactionRequest === true ? claudeFamilyVersion(parsed.modelId) : undefined; - const opusCompaction = version?.family === "opus" && version.major === 5 && version.minor === 5 - && usesNativeAnthropicEndpoint(provider); + // 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. + const version = claudeFamilyVersion(parsed.modelId); + const nativeOpus55 = version?.family === "opus" && version.major === 5 && version.minor === 5 + && isCanonicalAnthropicEndpoint(provider.baseUrl); + const opusCompaction = nativeOpus55 && parsed._compactionRequest === true; const requestedEffort = parsed.options.reasoning; const reasoningEffort = opusCompaction && requestedEffort !== "none" && requestedEffort !== "minimal" && requestedEffort !== "low" ? "medium" : requestedEffort; const maxOutputTokens = parsed.options.maxOutputTokens - ?? (opusCompaction ? OPUS_5_5_COMPACTION_MAX_TOKENS : undefined); + ?? (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. @@ -912,8 +917,8 @@ export function createAnthropicAdapter(provider: CodexCommanderProviderConfig, c const explicitMaxOut = maxOutputTokens; const wantBudget = reasoningBudget(reasoningEffort); const floor = wantBudget + OUTPUT_HEADROOM; - // Preserve caller limits and the scoped compaction total. Ordinary omitted limits - // use heuristic sizing; adaptive thinking does not enforce wantBudget separately. + // Preserve caller limits and scoped model totals. Other omitted limits use + // heuristic sizing; adaptive thinking does not enforce wantBudget separately. body.max_tokens = explicitMaxOut !== undefined ? explicitMaxOut : Math.min(ADAPTIVE_THINKING_CEILING, Math.max(DEFAULT_MAX_TOKENS, floor)); @@ -993,6 +998,19 @@ export function createAnthropicAdapter(provider: CodexCommanderProviderConfig, c const budgetEncoder = new TextEncoder(); let currentBlockType = ""; + // Accumulated signature_delta fragments for the open thinking block; emitted once, whole, + // at content_block_stop so downstream sees exactly one signature per signed block. + let currentThinkingSignature = ""; + // Retained bytes charged to the translator budget for currentThinkingSignature. The SSE + // decoder releases its per-frame charge, so the cross-frame collector charges itself. + let currentThinkingSignatureBytes = 0; + const resetThinkingSignature = (): void => { + if (currentThinkingSignatureBytes > 0) { + budget.releaseRetained(currentThinkingSignatureBytes, { kind: "reasoning" }); + } + currentThinkingSignature = ""; + currentThinkingSignatureBytes = 0; + }; let currentToolCallId = ""; let currentToolCallName = ""; let currentToolCallJson = ""; @@ -1044,7 +1062,12 @@ export function createAnthropicAdapter(provider: CodexCommanderProviderConfig, c case "content_block_start": { const block = data.content_block as { type: string; id?: string; name?: string; data?: string } | undefined; if (!block) break; + if (currentBlockType === "thinking" || currentBlockType === "reasoning") { + yield { type: "error", message: "Anthropic stream started a new block before thinking content_block_stop — possible truncation" }; + return; + } currentBlockType = block.type; + resetThinkingSignature(); if (block.type === "tool_use") { currentToolCallId = usableToolUseId(block.id); currentToolCallName = toolNames.fromWire(block.name ?? ""); @@ -1075,9 +1098,20 @@ export function createAnthropicAdapter(provider: CodexCommanderProviderConfig, c // later text blocks independent. yield { type: "thinking_delta", thinking: delta.reasoning }; } else if (delta.type === "signature_delta" && typeof delta.signature === "string" && (currentBlockType === "thinking" || currentBlockType === "reasoning")) { - // Arrives once, just before the thinking block's content_block_stop; block-scoped - // so a stray signature on a non-thinking block can never be captured. - yield { type: "thinking_signature", signature: delta.signature }; + // Block-scoped so a stray signature on a non-thinking block can never be + // captured. Fragments are concatenated and released at content_block_stop. + const previousBytes = currentThinkingSignatureBytes; + const nextBytes = previousBytes + budgetEncoder.encode(delta.signature).byteLength; + const reservation = budget.reserveTransient(nextBytes, { kind: "reasoning" }); + try { + currentThinkingSignature += delta.signature; + currentThinkingSignatureBytes = nextBytes; + reservation.commitRetained(); + if (previousBytes > 0) budget.releaseRetained(previousBytes, { kind: "reasoning" }); + } catch (error) { + reservation.release(); + throw error; + } } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string" && currentBlockType === "tool_use") { // Forwarded immediately: the bridge maps each delta to a client-visible // response.function_call_arguments.delta frame, so withholding fragments until @@ -1099,6 +1133,12 @@ export function createAnthropicAdapter(provider: CodexCommanderProviderConfig, c break; } case "content_block_stop": { + if ((currentBlockType === "thinking" || currentBlockType === "reasoning") && currentThinkingSignature) { + // A signature is only valid for its completed block; a block cut off before + // its stop never yields a partial, unreplayable signature. + yield { type: "thinking_signature", signature: currentThinkingSignature }; + } + resetThinkingSignature(); if (currentBlockType === "tool_use") { // The non-stream path repairs an unparseable payload in toolUseArguments(); the // stream cannot, because the fragments are already downstream. Fail the turn @@ -1128,6 +1168,10 @@ export function createAnthropicAdapter(provider: CodexCommanderProviderConfig, c break; } case "message_stop": { + if (currentBlockType === "thinking" || currentBlockType === "reasoning") { + yield { type: "error", message: "Anthropic stream ended before thinking content_block_stop — possible truncation" }; + return; + } yield* emitDone(); break; } @@ -1153,8 +1197,13 @@ export function createAnthropicAdapter(provider: CodexCommanderProviderConfig, c return; } finally { if (currentToolCallId) budget.closeCall(currentToolCallId); + resetThinkingSignature(); } if (!emittedDone) { + if (currentBlockType === "thinking" || currentBlockType === "reasoning") { + yield { type: "error", message: "Anthropic stream ended before thinking content_block_stop — possible truncation" }; + return; + } // Fail closed on transport EOF. Compatible providers may omit message_stop after message_delta.stop_reason. if (pendingStopReason !== undefined) { const stopReason = pendingStopReason === "max_tokens" diff --git a/src/bridge.ts b/src/bridge.ts index aabde0746bb..990704d84a8 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -900,6 +900,7 @@ export function bridgeToResponsesSSE( } case "text_delta": { if (currentReasoning) closeCurrentReasoning(); + flushHiddenReasoningEnvelope(); if (currentRawReasoning) closeCurrentRawReasoning(); flushHiddenRawReasoning(); // Reasoning consumed by a text turn, not a tool call: no cache target. @@ -963,10 +964,13 @@ export function bridgeToResponsesSSE( case "thinking_signature": { pendingSignatureBytes = replaceRetainedString(pendingSignatureBytes, event.signature, "reasoning"); pendingSignature = event.signature; - // Signature arrives at the end of the thinking block. With a visible reasoning item - // open, closeCurrentReasoning attaches the envelope; hidden/suppressed blocks flush - // an envelope-only reasoning item now. - if (!currentReasoning) flushHiddenReasoningEnvelope(); + // The adapter emits one complete signature per thinking block, just before its + // content_block_stop, so it marks the block boundary. Close the visible item now + // (matching buildResponseJSON): otherwise an adjacent thinking block would append + // to this item and its text would be paired with only the later signature. + // Hidden/suppressed blocks flush an envelope-only reasoning item. + if (currentReasoning) closeCurrentReasoning(); + else flushHiddenReasoningEnvelope(); break; } case "redacted_thinking": { @@ -1023,6 +1027,7 @@ export function bridgeToResponsesSSE( case "tool_call_start": { if (currentMsg) closeCurrentMessage("commentary"); if (currentReasoning) closeCurrentReasoning(); + flushHiddenReasoningEnvelope(); if (currentRawReasoning) closeCurrentRawReasoning(); flushHiddenRawReasoning(); if (rawReasoningForNextToolCall) { @@ -1663,7 +1668,7 @@ function buildResponseJSONWithBudget( // Only flush on an explicit phase change. A later delta that omits `phase` must keep // appending under the previously established phase. if (currentText && e.phase !== undefined && currentTextPhase !== e.phase) flushText("commentary"); - if (currentSummaryReasoning) flushSummaryReasoning(); + flushSummaryReasoning(); if (currentRawReasoning) flushRawReasoning(); rawReasoningForNextToolCall = ""; if (currentToolCallId) flushToolCall(); @@ -1736,7 +1741,7 @@ function buildResponseJSONWithBudget( break; case "tool_call_start": if (currentText) flushText("commentary"); - if (currentSummaryReasoning) flushSummaryReasoning(); + flushSummaryReasoning(); if (currentRawReasoning) flushRawReasoning(); if (rawReasoningForNextToolCall) { rememberReasoningForCall(e.id, rawReasoningForNextToolCall, replayCacheScope); diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index b24476480b5..1f17c4c1cab 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -879,7 +879,24 @@ function modelInputModalities( return undefined; } -export function catalogHintsFromModelsApiItem(providerName: string, item: ProviderModelsApiItem): Partial { +function isOfficialAnthropicModelsApi(source: { adapter: string; url: string } | undefined): boolean { + if (source?.adapter !== "anthropic") return false; + try { + const url = new URL(source.url); + return url.origin === "https://api.anthropic.com" + && url.pathname === "/v1/models" + && !url.username + && !url.password; + } catch { + return false; + } +} + +export function catalogHintsFromModelsApiItem( + providerName: string, + item: ProviderModelsApiItem, + source?: { adapter: string; url: string }, +): Partial { const metadata = plainRecord(item.metadata); const capabilityRecord = plainRecord(metadata?.capabilities) ?? plainRecord(item.capabilities); const limits = plainRecord(metadata?.limits); @@ -891,7 +908,11 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid item.context_size, item.max_model_len, item.max_context_length, - ); + ) + // Anthropic defines this field as the model's maximum input context window. + // Other APIs use it as a separate input ceiling, so require actual source evidence. + // https://platform.claude.com/docs/en/api/models/list + ?? (isOfficialAnthropicModelsApi(source) ? positiveSafeInteger(item.max_input_tokens) : undefined); const maxInputTokens = positiveSafeInteger(limits?.max_input_tokens, item.max_input_tokens); const rawReasoningEfforts = capabilityRecord?.reasoning_effort ?? item.reasoning_efforts; const listedReasoningEfforts = normalizedStringList(rawReasoningEfforts, 8, 24); @@ -1137,7 +1158,7 @@ async function fetchProviderModelsWithAuth( id: m.id, provider: name, ...(ownedBy ? { owned_by: ownedBy } : {}), - ...catalogHintsFromModelsApiItem(name, m), + ...catalogHintsFromModelsApiItem(name, m, { adapter: prov.adapter, url: request.url }), }, contextCap); }) .filter(m => shouldExposeProviderModel(name, m.id)); diff --git a/src/lib/anthropic-endpoint.ts b/src/lib/anthropic-endpoint.ts new file mode 100644 index 00000000000..bae11ff3b73 --- /dev/null +++ b/src/lib/anthropic-endpoint.ts @@ -0,0 +1,23 @@ +const NATIVE_ANTHROPIC_ORIGIN = "https://api.anthropic.com"; + +/** Canonical API root for supported native Anthropic base URLs; never rewrites custom destinations. */ +export function canonicalAnthropicApiRoot(baseUrl: string): string | undefined { + try { + const url = new URL(baseUrl); + if (url.origin !== NATIVE_ANTHROPIC_ORIGIN + || url.username + || url.password + || /[?#]/.test(url.href) + || !/^(?:\/v1(?:\/messages)?)?$/i.test(url.pathname.replace(/\/+$/, ""))) { + return undefined; + } + return NATIVE_ANTHROPIC_ORIGIN; + } catch { + return undefined; + } +} + +/** Shared native endpoint boundary for model budgets and catalog metadata. */ +export function isCanonicalAnthropicEndpoint(baseUrl: string): boolean { + return canonicalAnthropicApiRoot(baseUrl) !== undefined; +} diff --git a/src/oauth/index.ts b/src/oauth/index.ts index b3421d8ddb9..9322079356b 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -9,6 +9,7 @@ import { withConfigMutationLockSync, } from "../config"; import { maskEmail } from "../lib/privacy"; +import { canonicalAnthropicApiRoot } from "../lib/anthropic-endpoint"; import { KiroTokenRefreshError, loginKiro, refreshKiroToken, settleKiroLoginTransaction } from "./kiro"; import { getAccountCredential, getAccountSet, removeAccount, saveAccountCredential, saveCredential, setActiveAccount, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, markOAuthRefreshIntentStaleOwner, clearOAuthRefreshIntent, normalizeAuthStoreBuffer, OAuthMutationBusyError } from "./store"; import { loginXai, refreshXaiToken, XaiTokenRequestError } from "./xai"; @@ -858,7 +859,8 @@ export function buildModelsRequest( return { url: discoveryUrl(`${effectiveProvider.baseUrl}/v1beta/models?pageSize=1000`), headers }; } if (effectiveProvider.adapter === "anthropic") { - const base = effectiveProvider.baseUrl.replace(/\/v1\/?$/, ""); + const base = canonicalAnthropicApiRoot(effectiveProvider.baseUrl) + ?? effectiveProvider.baseUrl.replace(/\/v1\/?$/, ""); headers["anthropic-version"] = "2023-06-01"; if (effectiveProvider.authMode === "oauth") { headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA; diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 87c56140eeb..3a111f52593 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -1,5 +1,5 @@ import type { CodexAccountMode, CodexCommanderProviderConfig } from "../types"; -import { PROVIDER_REGISTRY, providerMatchesRegistryTransport, type ProviderRegistryEntry } from "./registry"; +import { PROVIDER_REGISTRY, providerMatchesRegistryMetadataDestination, providerMatchesRegistryTransport, type ProviderRegistryEntry } from "./registry"; export interface DerivedKeyLoginProvider { label: string; @@ -298,7 +298,18 @@ export function enrichProviderFromRegistry(name: string, prov: CodexCommanderPro if (!prov.models && seed.models) prov.models = [...seed.models]; if (prov.liveModels === undefined && seed.liveModels !== undefined) prov.liveModels = seed.liveModels; if (prov.contextWindow === undefined && seed.contextWindow !== undefined) prov.contextWindow = seed.contextWindow; - if (seed.modelContextWindows) prov.modelContextWindows = fillRecord(seed.modelContextWindows, prov.modelContextWindows); + if (seed.modelContextWindows) { + const nativeAnthropic = entry.adapter === "anthropic" && entry.jawcodeBundle === "anthropic"; + // Native Claude context limits describe Anthropic's endpoint, not a custom relay saved + // under the same preset name. Explicit user model maps remain authoritative everywhere. + if (!nativeAnthropic || providerMatchesRegistryMetadataDestination(name, prov)) { + const providerWindow = prov.contextWindow; + const contextDefaults = nativeAnthropic && typeof providerWindow === "number" && providerWindow > 0 + ? Object.fromEntries(Object.entries(seed.modelContextWindows).map(([id, window]) => [id, Math.min(window, providerWindow)])) + : seed.modelContextWindows; + prov.modelContextWindows = fillRecord(contextDefaults, prov.modelContextWindows); + } + } if (seed.modelInputModalities) prov.modelInputModalities = fillRecordOfArrays(seed.modelInputModalities, prov.modelInputModalities); if (prov.defaultMaxOutputTokens === undefined && seed.defaultMaxOutputTokens !== undefined) prov.defaultMaxOutputTokens = seed.defaultMaxOutputTokens; if (!prov.modelMaxOutputTokens && seed.modelMaxOutputTokens) prov.modelMaxOutputTokens = { ...seed.modelMaxOutputTokens }; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 1272e5386f3..5b8a9ede3e0 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -16,6 +16,7 @@ import { cursorModelReasoningEfforts, } from "../adapters/cursor/discovery"; import { COMMAND_CODE_MODEL_REASONING_EFFORTS } from "./command-code-efforts"; +import { isCanonicalAnthropicEndpoint } from "../lib/anthropic-endpoint"; export type ProviderAuthKind = "forward" | "oauth" | "key" | "local"; export type MetadataModelIdNormalize = "case-insensitive"; @@ -261,8 +262,9 @@ export type ProviderConfigSeed = Pick< // Shared between the OAuth (Claude account) and API-key Anthropic entries so both expose the // same static model seed. // Model and context-window snapshot refreshed 2026-07-10. -const ANTHROPIC_MODELS = ["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-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 }; +// 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_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]"]; const ZAI_GLM_52_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; @@ -2225,6 +2227,19 @@ function normalizedProviderEndpoint(value: string): string { } } +/** Configured endpoint evidence for registry metadata; does not decide routing or auth. */ +export function providerMatchesRegistryMetadataDestination( + id: string, + provider: Pick, +): boolean { + const entry = getProviderRegistryEntry(id); + if (!entry || provider.adapter !== entry.adapter) return false; + if (entry.adapter === "anthropic" && entry.jawcodeBundle === "anthropic") { + return isCanonicalAnthropicEndpoint(provider.baseUrl); + } + return normalizedProviderEndpoint(provider.baseUrl) === normalizedProviderEndpoint(entry.baseUrl); +} + /** * Whether registry transport defaults own this configured row. * diff --git a/src/responses/parser.ts b/src/responses/parser.ts index f31f7f906a1..392e9e647d5 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -279,7 +279,7 @@ function findToolById(messages: CodexCommanderMessage[], callId: string): { name function attachPendingReasoningToCallOwner( messages: CodexCommanderMessage[], callId: string, - pendingReasoning: Array<{ part: CodexCommanderThinkingContent; envelopeSigned: boolean }>, + pendingReasoning: Array<{ part: CodexCommanderThinkingContent; preserveEnvelopeBoundary: boolean }>, ): void { if (pendingReasoning.length === 0 || !callId) return; for (let i = messages.length - 1; i >= 0; i--) { @@ -310,7 +310,7 @@ export function parseRequest(body: unknown): CodexCommanderParsedRequest { const systemPrompt: string[] = []; // Responses reasoning siblings belong to the following assistant, including across call items. // Keep them off the message list until that assistant arrives; turn boundaries clear the array. - const pendingReasoning: Array<{ part: CodexCommanderThinkingContent; envelopeSigned: boolean }> = []; + const pendingReasoning: Array<{ part: CodexCommanderThinkingContent; preserveEnvelopeBoundary: boolean }> = []; // Assistant placeholder that first folds any pending reasoning into the same turn (official // grok-build preserves reasoning across call items; Anthropic replay requires thinking to // precede tool_use inside one assistant message). @@ -461,26 +461,33 @@ export function parseRequest(body: unknown): CodexCommanderParsedRequest { continue; } - // Native/non-ccxr1 encrypted-only reasoning is opaque here. Do not create a detached - // assistant turn or invent replayable plaintext/signatures from the encrypted payload. - if (thinkingText.length > 0) { + // A decoded ccxr1 envelope carrying a signature or redacted blocks is replayable provider + // state even with empty text: Opus adaptive thinking returns empty signed blocks, and + // dropping them loses that turn's reasoning. Native/non-ccxr1 encrypted-only reasoning + // stays opaque — never invent replayable plaintext/signatures from it. + const opaqueEnvelope = typeof envelope?.sig === "string" || (envelope?.red?.length ?? 0) > 0; + if (thinkingText.length > 0 || opaqueEnvelope) { const part: CodexCommanderThinkingContent = { type: "thinking", thinking: thinkingText, - signature: envelope?.sig ?? JSON.stringify(reasoning), + ...(typeof envelope?.sig === "string" + ? { signature: envelope.sig } + : thinkingText.length > 0 ? { signature: JSON.stringify(reasoning) } : {}), ...(envelope?.red ? { redacted: envelope.red } : {}), ...(reasoning.id ? { itemId: reasoning.id } : {}), }; - const envelopeSigned = typeof envelope?.sig === "string"; + // Envelope-backed parts are block boundaries: merging would drop redacted payloads or + // re-pair a signature with text it did not sign. + const preserveEnvelopeBoundary = opaqueEnvelope; const previous = pendingReasoning[pendingReasoning.length - 1]; - if (!envelopeSigned && previous && !previous.envelopeSigned) { + if (!preserveEnvelopeBoundary && previous && !previous.preserveEnvelopeBoundary) { previous.part = { ...part, thinking: `${previous.part.thinking}\n${part.thinking}`, }; } else { - pendingReasoning.push({ part, envelopeSigned }); + pendingReasoning.push({ part, preserveEnvelopeBoundary }); } } continue; diff --git a/src/server/responses/terminal-guard.ts b/src/server/responses/terminal-guard.ts index d5cffff81c1..365a894fe03 100644 --- a/src/server/responses/terminal-guard.ts +++ b/src/server/responses/terminal-guard.ts @@ -118,21 +118,36 @@ export function analyzeTerminalTurn(parsed: CodexCommanderParsedRequest, events: } function assistantMessageFromEvents(events: readonly AdapterEvent[]): CodexCommanderAssistantMessage | undefined { + const content: CodexCommanderAssistantContentPart[] = []; let text = ""; let thinking = ""; - let signature: string | undefined; - const redacted: string[] = []; + const flushText = () => { + if (text) content.push({ type: "text", text }); + text = ""; + }; + const flushThinking = (signature?: string) => { + if (thinking || signature) content.push({ type: "thinking", thinking, ...(signature ? { signature } : {}) }); + thinking = ""; + }; for (const event of events) { - if (event.type === "text_delta") text += event.text; - else if (event.type === "thinking_delta") thinking += event.thinking; - else if (event.type === "thinking_signature") signature = event.signature; - else if (event.type === "redacted_thinking") redacted.push(event.data); - } - const content: CodexCommanderAssistantContentPart[] = []; - if (thinking || signature || redacted.length > 0) { - content.push({ type: "thinking", thinking, ...(signature ? { signature } : {}), ...(redacted.length > 0 ? { redacted } : {}) }); + if (event.type === "text_delta") { + flushThinking(); + text += event.text; + } else if (event.type === "thinking_delta") { + flushText(); + thinking += event.thinking; + } else if (event.type === "thinking_signature") { + flushText(); + // The complete signature closes only this block, including an empty signed block. + flushThinking(event.signature); + } else if (event.type === "redacted_thinking") { + flushText(); + flushThinking(); + content.push({ type: "thinking", thinking: "", redacted: [event.data] }); + } } - if (text) content.push({ type: "text", text }); + flushText(); + flushThinking(); if (content.length === 0) return undefined; return { role: "assistant", content, timestamp: Date.now() }; } diff --git a/src/types.ts b/src/types.ts index a7a2902aaac..a66929279f8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -254,8 +254,9 @@ export type AdapterEvent = | { type: "heartbeat" } | { type: "text_delta"; text: string; phase?: CodexCommanderMessagePhase } | { type: "thinking_delta"; thinking: string } - // Anthropic extended-thinking round-trip: signature_delta for the current thinking block, and - // opaque redacted_thinking blocks. Both must be replayed verbatim or tool-use turns 400. + // Anthropic extended-thinking round-trip: the complete signature of a finished thinking block + // (emitted once at the block boundary; it closes that block), and opaque redacted_thinking + // blocks. Both must be replayed verbatim or tool-use turns 400. | { type: "thinking_signature"; signature: string } | { type: "redacted_thinking"; data: string } // Kiro reasoning round-trip: the encrypted `redactedContent` blob for the CURRENT assistant turn. diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index 9611263af79..2494fdcb7dd 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -168,6 +168,13 @@ Routed entries keep Codex-required metadata such as reasoning levels, shell type base instructions, modalities, auto-compact fields, and strict parser booleans. The public slug and display name use `provider/model`. +Canonical Anthropic Models API discovery maps `max_input_tokens` to the context window when +no explicit context field is present. Registry context metadata supplies the known Opus 5.5 +1,000,000-token fallback for the native destination, including offline catalog generation; +the existing 90% policy then sets auto-compaction to 900,000. Explicit provider/model context +settings and context caps remain authoritative. Native seed context defaults are destination-bound +without changing credential or transport matching, so custom gateways do not acquire native limits. + ## Native passthrough Native bare OpenAI entries form one `openai` group. The provider's Pool(default)/Direct option diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index aeb47978768..efa201bfc07 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -33,14 +33,19 @@ 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. + 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` without raising lower efforts, and an omitted output limit becomes 64,000 total tokens. Explicit limits remain authoritative. Adaptive thinking has no fixed `budget_tokens` -reservation, so the ordinary effort-to-output heuristic cannot guarantee room for summary +reservation, so an effort-to-output heuristic cannot guarantee room for summary text. Both v1 compact and v2 `compaction_trigger` requests carry `_compactionRequest` to this -boundary; replayed summaries do not activate it. Other models, custom destinations, and ordinary -work retain their existing behavior. Token-limit stops remain incomplete and install no partial +boundary; replayed summaries do not activate it. Token-limit stops remain incomplete and install no partial compaction item; there is no automatic retry or model substitution. Routed Anthropic v1 compact generation streams internally and consumes the bounded Responses terminal through the SSE decoder before building the v1 JSON replacement history. This preserves @@ -452,8 +457,12 @@ URL keeps the generic `max_tokens` default unless it opts in explicitly. assistant turn (`pendingReasoning` in `src/responses/parser.ts`) so the Grok chat wire carries ONE assistant message with `reasoning_content` — exact-prefix cache stability. Unsigned siblings newline-join; `ccxr1`-signed siblings stay separate parts (Anthropic replay keeps - each signature on its own text); boundaries (user/tool-result/agent) clear pending state; - call items fold pending reasoning into the same turn. + each signature on its own text, including valid empty signed or redacted blocks). Streaming + closes each completed signed block separately, matching JSON reconstruction; boundaries + (user/tool-result/agent) clear pending state; + call items fold pending reasoning into the same turn. Terminal-guard continuations preserve + those block boundaries and order too. An open Anthropic thinking block cannot finish successfully + at EOF, `message_stop`, or a new content-block start. - **Grok CLI credential ownership:** `source:"local-cli"` xAI credentials re-read `~/.grok/auth.json` (read-only) and adopt a newer usable generation with zero IdP calls (`shouldAdoptGrokGeneration`, later-expiresAt authority). CodexCommander never submits the native diff --git a/tests/anthropic-reasoning.test.ts b/tests/anthropic-reasoning.test.ts index 92c999973d3..739cf190bec 100644 --- a/tests/anthropic-reasoning.test.ts +++ b/tests/anthropic-reasoning.test.ts @@ -79,21 +79,109 @@ describe("anthropic extended-thinking gate", () => { else expect(b.thinking).toMatchObject({ type: "enabled" }); }); - test("ordinary Opus 5.5 max effort keeps its existing default", async () => { - const b = await bodyOf(parsed("max", {}, "claude-opus-5-5")); - expect(b.max_tokens).toBe(40_192); + test.each([ + "claude-opus-5-5", + "anthropic/claude-opus-5-5", + "claude-opus-5-5-20260922", + "claude-opus-5-5[1m]", + "claude-opus-5-5/vendor-suffix", + ])("ordinary native Opus 5.5 %s gets its documented long-turn default", async modelId => { + const request = parsed("max", {}, modelId); + const b = await bodyOf(request); + expect(b.max_tokens).toBe(128_000); + expect(b.thinking).toEqual({ type: "adaptive" }); expect(b.output_config).toEqual({ effort: "max" }); + expect(request.options).toEqual({ reasoning: "max" }); }); - test("custom Anthropic-compatible destinations keep their existing Opus 5.5 limits", async () => { + test.each([ + [undefined, undefined], ["none", undefined], ["minimal", "low"], ["low", "low"], + ["medium", "medium"], ["high", "high"], ["xhigh", "xhigh"], ["max", "max"], + ["ultra", "max"], ["ludicrous", "high"], + ])("ordinary native Opus 5.5 default preserves requested effort %s", async (effort, expected) => { + const request = parsed(effort, {}, "claude-opus-5-5"); + const b = await bodyOf(request); + expect(b.max_tokens).toBe(128_000); + expect(b.output_config).toEqual(expected ? { effort: expected } : undefined); + expect(b.thinking).toEqual(expected ? { type: "adaptive" } : undefined); + expect(request.options.reasoning).toBe(effort); + }); + + test.each([4096, 64_000, 160_000])("ordinary native Opus 5.5 preserves explicit total %s", async maxOutputTokens => { + for (const effort of [undefined, "none", "max"]) { + const request = parsed(effort, { maxOutputTokens }, "claude-opus-5-5"); + const b = await bodyOf(request); + expect(b.max_tokens).toBe(maxOutputTokens); + expect(b.output_config).toEqual(effort === "max" ? { effort: "max" } : undefined); + expect(request.options.maxOutputTokens).toBe(maxOutputTokens); + } + }); + + 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) => { + const b = await bodyOf(parsed("max", {}, modelId)); + expect(b.max_tokens).toBe(expectedMax); + if (expectedMax === 40_192) expect(b.output_config).toEqual({ effort: "max" }); + else expect(b.thinking).toMatchObject({ type: "enabled" }); + }); + + test.each([false, true])("custom Anthropic-compatible destination keeps Opus 5.5 limits (compaction=%s)", async compaction => { const request = parsed("max", {}, "claude-opus-5-5"); - request._compactionRequest = true; + request._compactionRequest = compaction; const built = await createAnthropicAdapter({ ...provider, baseUrl: "https://gateway.example" }).buildRequest(request); const b = JSON.parse(String(built.body)); expect(b.max_tokens).toBe(40_192); expect(b.output_config).toEqual({ effort: "max" }); }); + test.each([ + "http://api.anthropic.com", + "https://api.anthropic.com:8443/v1/messages", + ["https://fixture-user:fixture-pass", "api.anthropic.com/v1/messages"].join("@"), + "https://api.anthropic.com/v1/messages?relay=1", + "https://api.anthropic.com/v1/messages#relay", + "https://api.anthropic.com/v1/messages?", + "https://api.anthropic.com/v1/messages#", + "https://api.anthropic.com/proxy/v1/messages", + "https://api.anthropic.com/v1/custom", + "https://api.anthropic.com.evil.example/v1/messages", + ])("non-native Anthropic URL %s retains legacy Opus budgets and effort", async baseUrl => { + for (const compaction of [false, true]) { + const request = parsed("max", {}, "claude-opus-5-5"); + request._compactionRequest = compaction; + const built = await createAnthropicAdapter({ ...provider, baseUrl }).buildRequest(request); + const b = JSON.parse(String(built.body)); + expect(b.max_tokens).toBe(40_192); + expect(b.output_config).toEqual({ effort: "max" }); + expect(request.options).toEqual({ reasoning: "max" }); + } + }); + + test.each([ + "https://api.anthropic.com", + "https://api.anthropic.com/", + "https://api.anthropic.com/v1", + "https://api.anthropic.com/v1/", + "https://api.anthropic.com/v1/messages", + "https://api.anthropic.com/v1/messages/", + "https://api.anthropic.com/V1/MESSAGES///", + "https://api.anthropic.com:443/v1/messages", + ])("native Anthropic URL %s shares ordinary and compaction budgets", async baseUrl => { + for (const compaction of [false, true]) { + const request = parsed("max", {}, "claude-opus-5-5"); + request._compactionRequest = compaction; + const built = await createAnthropicAdapter({ ...provider, baseUrl }).buildRequest(request); + const b = JSON.parse(String(built.body)); + expect(b.max_tokens).toBe(compaction ? 64_000 : 128_000); + expect(b.output_config).toEqual({ effort: compaction ? "medium" : "max" }); + expect(request.options).toEqual({ reasoning: "max" }); + } + }); + test("reasoning 'none' does NOT enable thinking and preserves temperature/top_p", async () => { const b = await bodyOf(parsed("none", { temperature: 0.3, topP: 0.9 })); expect(b.thinking).toBeUndefined(); diff --git a/tests/anthropic-thinking-signature.test.ts b/tests/anthropic-thinking-signature.test.ts index 15138dbed72..864c0ce1e16 100644 --- a/tests/anthropic-thinking-signature.test.ts +++ b/tests/anthropic-thinking-signature.test.ts @@ -4,7 +4,7 @@ import { createAnthropicAdapter as createAnthropicAdapterProduction } from "../s import { parseRequest } from "../src/responses/parser"; import { encodeReasoningEnvelope, decodeReasoningEnvelope, CCX_REASONING_PREFIX } from "../src/responses/reasoning-envelope"; import type { AdapterEvent, CodexCommanderProviderConfig, CodexCommanderThinkingContent } from "../src/types"; -import { withTestTranslatorBudget } from "./helpers/translator-budget"; +import { createTestTranslatorBudget, withTestTranslatorBudget } from "./helpers/translator-budget"; const createAnthropicAdapter = (...args: Parameters) => withTestTranslatorBudget(createAnthropicAdapterProduction(...args)); @@ -326,3 +326,443 @@ describe("passthrough scrub of ccxr1 envelopes", () => { expect(req.body ?? "").toContain('"rs_1"'); // reasoning item itself survives }); }); + +describe("signed thinking block boundaries + opaque envelope preservation (0.1.25)", () => { + test("SSE adjacent signed thinking blocks keep each text paired with its own signature", async () => { + async function* gen(): AsyncGenerator { + yield { type: "thinking_delta", thinking: "first" }; + yield { type: "thinking_signature", signature: "FirstRealSignature123456==" }; + yield { type: "thinking_delta", thinking: "second" }; + yield { type: "thinking_signature", signature: "SecondRealSignature123456==" }; + yield { type: "text_delta", text: "answer" }; + yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } }; + } + const sse = await drainSse(bridgeToResponsesSSE(gen(), "claude-x")); + const reasoning = sseItems(sse).filter(i => i.type === "reasoning"); + expect(reasoning.map(i => ({ + text: (i.summary as { text: string }[]).map(s => s.text).join(""), + sig: decodeReasoningEnvelope(i.encrypted_content as string)?.sig, + }))).toEqual([ + { text: "first", sig: "FirstRealSignature123456==" }, + { text: "second", sig: "SecondRealSignature123456==" }, + ]); + }); + + test("SSE and JSON agree on mixed redacted/visible/empty-signed block order", async () => { + const events: AdapterEvent[] = [ + { type: "redacted_thinking", data: "RED_A" }, + { type: "thinking_delta", thinking: "visible one" }, + { type: "thinking_signature", signature: "SigOneRealSignature1234==" }, + { type: "redacted_thinking", data: "RED_B" }, + { type: "thinking_signature", signature: "SigEmptyRealSignature12==" }, + { type: "tool_call_start", id: "toolu_1", name: "shell" }, + { type: "tool_call_delta", arguments: "{}" }, + { type: "tool_call_end" }, + { type: "done", usage: { inputTokens: 1, outputTokens: 1 } }, + ]; + async function* gen(): AsyncGenerator { yield* events; } + const sse = await drainSse(bridgeToResponsesSSE(gen(), "claude-x")); + const shape = (items: Record[]) => items.filter(i => i.type === "reasoning").map(i => { + const env = decodeReasoningEnvelope(i.encrypted_content as string); + return { sig: env?.sig, red: env?.red }; + }); + const expected = [ + { sig: "SigOneRealSignature1234==", red: ["RED_A"] }, + { sig: "SigEmptyRealSignature12==", red: ["RED_B"] }, + ]; + expect(shape(sseItems(sse))).toEqual(expected); + expect(shape(buildResponseJSON(events, "claude-x").output as Record[])).toEqual(expected); + }); + + test("parser keeps a ccxr1 signature-only envelope as an empty signed thinking block", () => { + const parsed = parseRequest({ + model: "anthropic/claude-x", + input: [ + { type: "reasoning", id: "rs_empty", summary: [], encrypted_content: encodeReasoningEnvelope({ sig: "EmptySignedRealSig123456==" }) }, + { type: "function_call", call_id: "call_1", name: "shell", arguments: "{}" }, + { type: "function_call_output", call_id: "call_1", output: "done" }, + ], + }); + const assistant = parsed.context.messages.find(m => m.role === "assistant") as { content: { type: string }[] }; + expect(assistant.content.map(p => p.type)).toEqual(["thinking", "toolCall"]); + const thinking = assistant.content[0] as CodexCommanderThinkingContent; + expect(thinking.thinking).toBe(""); + expect(thinking.signature).toBe("EmptySignedRealSig123456=="); + }); + + test("parser keeps a ccxr1 redacted-only envelope without inventing a signature", () => { + const parsed = parseRequest({ + model: "anthropic/claude-x", + input: [ + { type: "reasoning", id: "rs_red", summary: [], encrypted_content: encodeReasoningEnvelope({ red: ["ONLY_RED"] }) }, + { type: "message", role: "assistant", content: [{ type: "output_text", text: "ok" }] }, + ], + }); + const assistant = parsed.context.messages.find(m => m.role === "assistant") as { content: { type: string }[] }; + const thinking = assistant.content.find(p => p.type === "thinking") as CodexCommanderThinkingContent | undefined; + expect(thinking?.redacted).toEqual(["ONLY_RED"]); + expect(thinking?.signature).toBeUndefined(); + expect(thinking?.thinking).toBe(""); + }); + + test("parser keeps adjacent unsigned text from absorbing a redacted-only envelope", () => { + const parsed = parseRequest({ + model: "anthropic/claude-x", + input: [ + { type: "reasoning", id: "rs_red", summary: [{ type: "summary_text", text: "redacted note" }], encrypted_content: encodeReasoningEnvelope({ red: ["KEEP_RED"] }) }, + { type: "reasoning", id: "rs_plain", summary: [{ type: "summary_text", text: "plain" }] }, + { type: "message", role: "assistant", content: [{ type: "output_text", text: "ok" }] }, + ], + }); + const assistant = parsed.context.messages.find(m => m.role === "assistant") as { content: { type: string }[] }; + const thinking = assistant.content.filter(p => p.type === "thinking") as CodexCommanderThinkingContent[]; + expect(thinking.flatMap(p => p.redacted ?? [])).toEqual(["KEEP_RED"]); + }); + + test("native encrypted-only and invalid ccxr1 reasoning remain dropped", () => { + const parsed = parseRequest({ + model: "anthropic/claude-x", + input: [ + { type: "reasoning", id: "rs_native", summary: [], encrypted_content: "gAAAAABopaqueOpenAI" }, + { type: "reasoning", id: "rs_bad", summary: [], encrypted_content: `${CCX_REASONING_PREFIX}!!notbase64json` }, + { type: "message", role: "assistant", content: [{ type: "output_text", text: "ok" }] }, + ], + }); + const assistant = parsed.context.messages.find(m => m.role === "assistant") as { content: { type: string }[] }; + expect(assistant.content.map(p => p.type)).toEqual(["text"]); + }); + + for (const mode of ["sse", "json"] as const) { + test(`${mode}: Opus empty-signed + adjacent signed blocks survive response → parseRequest → Anthropic tool-result continuation`, async () => { + const events: AdapterEvent[] = [ + { type: "thinking_signature", signature: "EmptySignedRealSig123456==" }, + { type: "redacted_thinking", data: "RED_MID" }, + { type: "thinking_delta", thinking: "first" }, + { type: "thinking_signature", signature: "FirstRealSignature123456==" }, + { type: "thinking_delta", thinking: "second" }, + { type: "thinking_signature", signature: "SecondRealSignature123456==" }, + { type: "tool_call_start", id: "toolu_1", name: "shell" }, + { type: "tool_call_delta", arguments: "{}" }, + { type: "tool_call_end" }, + { type: "done", usage: { inputTokens: 1, outputTokens: 1 }, stopReason: "toolUse" } as AdapterEvent, + ]; + let output: Record[]; + if (mode === "sse") { + async function* gen(): AsyncGenerator { yield* events; } + output = sseItems(await drainSse(bridgeToResponsesSSE(gen(), "claude-x"))); + } else { + output = buildResponseJSON(events, "claude-x").output as Record[]; + } + const call = output.find(i => i.type === "function_call") as { call_id: string }; + const parsed = parseRequest({ + model: "anthropic/claude-x", + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "go" }] }, + ...output, + { type: "function_call_output", call_id: call.call_id, output: "done" }, + ], + }); + const adapter = createAnthropicAdapter(provider); + const body = JSON.parse((await adapter.buildRequest(parsed) as { body: string }).body) as { + messages: Array<{ role: string; content: Array> | string }>; + }; + const assistant = body.messages.find(m => m.role === "assistant")!; + const blocks = (assistant.content as Array>).map(b => { + if (b.type === "thinking") return { type: b.type, thinking: b.thinking, signature: b.signature }; + if (b.type === "redacted_thinking") return { type: b.type, data: b.data }; + return { type: b.type }; + }); + expect(blocks).toEqual([ + { type: "thinking", thinking: "", signature: "EmptySignedRealSig123456==" }, + { type: "redacted_thinking", data: "RED_MID" }, + { type: "thinking", thinking: "first", signature: "FirstRealSignature123456==" }, + { type: "thinking", thinking: "second", signature: "SecondRealSignature123456==" }, + { type: "tool_use" }, + ]); + const next = body.messages[body.messages.indexOf(assistant) + 1]; + expect(next.role).toBe("user"); + expect((next.content as Array<{ type: string }>)[0].type).toBe("tool_result"); + }); + } +}); + +describe("anthropic signature fragments at the block boundary (0.1.25)", () => { + const fragmentedStream = () => sseResponse([ + frame("message_start", { message: { usage: { input_tokens: 1 } } }), + frame("content_block_start", { index: 0, content_block: { type: "thinking", thinking: "" } }), + frame("content_block_delta", { index: 0, delta: { type: "thinking_delta", thinking: "first" } }), + frame("content_block_delta", { index: 0, delta: { type: "signature_delta", signature: "FirstReal" } }), + frame("content_block_delta", { index: 0, delta: { type: "signature_delta", signature: "Signature123456==" } }), + frame("content_block_stop", { index: 0 }), + frame("content_block_start", { index: 1, content_block: { type: "thinking", thinking: "" } }), + frame("content_block_delta", { index: 1, delta: { type: "signature_delta", signature: "EmptySigned" } }), + frame("content_block_delta", { index: 1, delta: { type: "signature_delta", signature: "RealSig123456==" } }), + frame("content_block_stop", { index: 1 }), + frame("content_block_start", { index: 2, content_block: { type: "thinking", thinking: "" } }), + frame("content_block_delta", { index: 2, delta: { type: "thinking_delta", thinking: "second" } }), + frame("content_block_delta", { index: 2, delta: { type: "signature_delta", signature: "SecondRealSignature123456==" } }), + frame("content_block_stop", { index: 2 }), + frame("content_block_start", { index: 3, content_block: { type: "text", text: "" } }), + frame("content_block_delta", { index: 3, delta: { type: "text_delta", text: "answer" } }), + frame("content_block_stop", { index: 3 }), + frame("message_delta", { delta: { stop_reason: "end_turn" }, usage: { output_tokens: 3 } }), + frame("message_stop", {}), + ]); + const expected = [ + { text: "first", sig: "FirstRealSignature123456==" }, + { text: "", sig: "EmptySignedRealSig123456==" }, + { text: "second", sig: "SecondRealSignature123456==" }, + ]; + const shape = (items: Record[]) => items.filter(i => i.type === "reasoning").map(i => ({ + text: ((i.summary as { text: string }[] | undefined) ?? []).map(s => s.text).join(""), + sig: decodeReasoningEnvelope(i.encrypted_content as string)?.sig, + })); + + test("adapter emits one whole signature per completed thinking block", async () => { + const adapter = createAnthropicAdapter(provider); + const events = await collect(adapter.parseStream!(fragmentedStream())); + expect(events.filter(e => e.type === "thinking_signature")).toEqual(expected.map(e => ({ type: "thinking_signature", signature: e.sig }))); + }); + + test("adapter never emits a partial signature for a block cut off before content_block_stop", async () => { + const adapter = createAnthropicAdapter(provider); + const budget = createTestTranslatorBudget(); + const events = await collect(adapter.parseStream!(sseResponse([ + frame("message_start", { message: { usage: { input_tokens: 1 } } }), + frame("content_block_start", { index: 0, content_block: { type: "thinking", thinking: "" } }), + frame("content_block_delta", { index: 0, delta: { type: "signature_delta", signature: "PartialFragment" } }), + ]), budget)); + expect(events.filter(e => e.type === "error")).toHaveLength(1); + expect(events.at(-1)?.type).toBe("error"); + expect(events.some(e => e.type === "thinking_signature" || e.type === "done")).toBe(false); + expect(budget.snapshot().currentBytes).toBe(0); + }); + + test("adapter SSE → bridge SSE and JSON agree on whole per-block signatures", async () => { + const adapter = createAnthropicAdapter(provider); + const sse = await drainSse(bridgeToResponsesSSE(adapter.parseStream!(fragmentedStream()), "claude-x")); + expect(shape(sseItems(sse))).toEqual(expected); + const events = await collect(createAnthropicAdapter(provider).parseStream!(fragmentedStream())); + expect(shape(buildResponseJSON(events, "claude-x").output as Record[])).toEqual(expected); + }); +}); + +describe("trailing Anthropic redacted blocks preserve replay order", () => { + for (const mode of ["sse", "json"] as const) { + for (const hideThinkingSummary of [false, true]) { + for (const next of ["tool", "text"] as const) { + test(`${mode}, hidden=${hideThinkingSummary}: signed thinking then redacted block stays before ${next}`, async () => { + const signature = "FirstRealSignature123456=="; + const adapter = createAnthropicAdapter(provider); + let output: Record[]; + if (mode === "sse") { + const frames = [ + frame("message_start", { message: {} }), + frame("content_block_start", { index: 0, content_block: { type: "thinking", thinking: "" } }), + frame("content_block_delta", { index: 0, delta: { type: "thinking_delta", thinking: "first" } }), + frame("content_block_delta", { index: 0, delta: { type: "signature_delta", signature } }), + frame("content_block_stop", { index: 0 }), + frame("content_block_start", { index: 1, content_block: { type: "redacted_thinking", data: "RED_AFTER_FIRST" } }), + frame("content_block_stop", { index: 1 }), + frame("content_block_start", { index: 2, content_block: next === "tool" + ? { type: "tool_use", id: "toolu_1", name: "shell" } : { type: "text", text: "" } }), + frame("content_block_delta", { index: 2, delta: next === "tool" + ? { type: "input_json_delta", partial_json: "{}" } : { type: "text_delta", text: "answer" } }), + frame("content_block_stop", { index: 2 }), + frame("message_delta", { delta: { stop_reason: next === "tool" ? "tool_use" : "end_turn" } }), + frame("message_stop", {}), + ]; + output = sseItems(await drainSse(bridgeToResponsesSSE( + adapter.parseStream(sseResponse(frames)), "claude-x", + undefined, undefined, undefined, undefined, 0, { hideThinkingSummary }, + ))); + } else { + const events = await adapter.parseResponse!(new Response(JSON.stringify({ + content: [ + { type: "thinking", thinking: "first", signature }, + { type: "redacted_thinking", data: "RED_AFTER_FIRST" }, + next === "tool" ? { type: "tool_use", id: "toolu_1", name: "shell", input: {} } + : { type: "text", text: "answer" }, + ], + stop_reason: next === "tool" ? "tool_use" : "end_turn", + }))); + output = buildResponseJSON(events, "claude-x", { hideThinkingSummary }).output as Record[]; + } + expect(output.map(item => item.type)).toEqual(["reasoning", "reasoning", next === "tool" ? "function_call" : "message"]); + if (hideThinkingSummary) expect(output.filter(item => item.type === "reasoning").every(item => (item.summary as unknown[]).length === 0)).toBe(true); + const parsed = parseRequest({ + model: "anthropic/claude-x", + input: [ + { type: "message", role: "user", content: "go" }, + ...output, + ...(next === "tool" ? [{ type: "function_call_output", call_id: "toolu_1", output: "done" }] + : [{ type: "message", role: "user", content: "next" }]), + ], + }); + const body = JSON.parse(String((await adapter.buildRequest(parsed)).body)) as { + messages: Array<{ role: string; content: Array> }>; + }; + const assistant = body.messages.find(message => message.role === "assistant")!; + expect(assistant.content.map(block => { + if (block.type === "thinking") return { type: block.type, thinking: block.thinking, signature: block.signature }; + if (block.type === "redacted_thinking") return { type: block.type, data: block.data }; + return { type: block.type }; + })).toEqual([ + { type: "thinking", thinking: "first", signature }, + { type: "redacted_thinking", data: "RED_AFTER_FIRST" }, + { type: next === "tool" ? "tool_use" : "text" }, + ]); + if (next === "tool") { + const toolResult = body.messages[body.messages.indexOf(assistant) + 1]; + expect(toolResult.role).toBe("user"); + expect(toolResult.content[0]).toMatchObject({ type: "tool_result", tool_use_id: "toolu_1" }); + } + }); + } + } + } +}); + +describe("unterminated Anthropic thinking blocks fail closed", () => { + function openThinkingFrames(type: "thinking" | "reasoning" = "thinking") { + return [ + frame("message_start", { message: {} }), + frame("content_block_start", { index: 0, content_block: { type } }), + frame("content_block_delta", { index: 0, delta: type === "thinking" + ? { type: "thinking_delta", thinking: "unfinished" } : { type: "reasoning_delta", reasoning: "unfinished" } }), + frame("content_block_delta", { index: 0, delta: { type: "signature_delta", signature: "PartialFragment" } }), + ]; + } + const endings = { + "stop reason then EOF": [frame("message_delta", { delta: { stop_reason: "end_turn" } })], + "message_stop": [frame("message_delta", { delta: { stop_reason: "tool_use" } }), frame("message_stop", {})], + "new content block": [ + frame("content_block_start", { index: 1, content_block: { type: "text", text: "" } }), + frame("content_block_delta", { index: 1, delta: { type: "text_delta", text: "answer" } }), + frame("content_block_stop", { index: 1 }), + frame("message_delta", { delta: { stop_reason: "end_turn" } }), frame("message_stop", {}), + ], + }; + for (const type of ["thinking", "reasoning"] as const) { + for (const [name, ending] of Object.entries(endings)) { + test(`${type}: ${name} cannot complete or publish a partial signature`, async () => { + const budget = createTestTranslatorBudget(); + const events = await collect(createAnthropicAdapter(provider).parseStream(sseResponse([ + ...openThinkingFrames(type), ...ending, + ]), budget)); + expect(events.filter(event => event.type === "error")).toHaveLength(1); + expect(events.at(-1)).toMatchObject({ type: "error", message: expect.stringContaining("content_block_stop") }); + expect(events.some(event => event.type === "done" || event.type === "thinking_signature" || event.type === "text_delta")).toBe(false); + expect(budget.snapshot().currentBytes).toBe(0); + }); + } + } + + test("EOF tolerance cannot complete visible text followed by an open thinking block", async () => { + const budget = createTestTranslatorBudget(); + const events = await collect(createAnthropicAdapter({ ...provider, anthropicEofTolerance: true }).parseStream(sseResponse([ + frame("content_block_start", { index: 0, content_block: { type: "text", text: "" } }), + frame("content_block_delta", { index: 0, delta: { type: "text_delta", text: "earlier visible text" } }), + frame("content_block_stop", { index: 0 }), + ...openThinkingFrames(), + ]), budget)); + expect(events.filter(event => event.type === "error")).toHaveLength(1); + expect(events.some(event => event.type === "done" || event.type === "thinking_signature")).toBe(false); + expect(budget.snapshot().currentBytes).toBe(0); + }); + + test("a closed signed thinking block may complete at stop_reason plus EOF", async () => { + const budget = createTestTranslatorBudget(); + const events = await collect(createAnthropicAdapter(provider).parseStream(sseResponse([ + ...openThinkingFrames(), frame("content_block_stop", { index: 0 }), + frame("message_delta", { delta: { stop_reason: "end_turn" } }), + ]), budget)); + expect(events.filter(event => event.type === "thinking_signature")).toEqual([{ type: "thinking_signature", signature: "PartialFragment" }]); + expect(events.filter(event => event.type === "done")).toHaveLength(1); + expect(events.some(event => event.type === "error")).toBe(false); + expect(budget.snapshot().currentBytes).toBe(0); + }); + + test("an upstream error discards signature fragments and releases their retained bytes", async () => { + const budget = createTestTranslatorBudget(); + const events = await collect(createAnthropicAdapter(provider).parseStream(sseResponse([ + ...openThinkingFrames(), frame("error", { error: { message: "fixture upstream error" } }), + ]), budget)); + expect(events.filter(event => event.type === "error")).toEqual([{ type: "error", message: "fixture upstream error" }]); + expect(events.some(event => event.type === "done" || event.type === "thinking_signature")).toBe(false); + expect(budget.snapshot().currentBytes).toBe(0); + }); + + test("transport failure releases an actually retained signature and propagates its error", async () => { + const budget = createTestTranslatorBudget(); + let controller!: ReadableStreamDefaultController; + const response = new Response(new ReadableStream({ start(value) { + controller = value; + controller.enqueue(new TextEncoder().encode([...openThinkingFrames(), frame("ping", {})].join(""))); + } })); + const iterator = createAnthropicAdapter(provider).parseStream(response, budget); + expect((await iterator.next()).value).toMatchObject({ type: "thinking_delta" }); + expect((await iterator.next()).value).toEqual({ type: "heartbeat" }); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + controller.error(new Error("fixture transport failure")); + await expect(iterator.next()).rejects.toThrow("fixture transport failure"); + expect(budget.snapshot().currentBytes).toBe(0); + }); + + test("iterator cancellation releases an actually retained signature and cancels upstream", async () => { + const budget = createTestTranslatorBudget(); + let cancelled = 0; + const response = new Response(new ReadableStream({ + start(controller) { controller.enqueue(new TextEncoder().encode([...openThinkingFrames(), frame("ping", {})].join(""))); }, + cancel() { cancelled++; }, + })); + const iterator = createAnthropicAdapter(provider).parseStream(response, budget); + expect((await iterator.next()).value).toMatchObject({ type: "thinking_delta" }); + expect((await iterator.next()).value).toEqual({ type: "heartbeat" }); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + await iterator.return(undefined); + expect(cancelled).toBe(1); + expect(budget.snapshot().currentBytes).toBe(0); + }); +}); + +describe("anthropic signature collector translator budget (0.1.25)", () => { + test("many small signature fragments over the turn cap fail once with translation_buffer_limit and release retained bytes", async () => { + const adapter = createAnthropicAdapter(provider); + const budget = createTestTranslatorBudget({ maxTurnBytes: 4096 }); + const fragments = Array.from({ length: 400 }, () => + frame("content_block_delta", { index: 0, delta: { type: "signature_delta", signature: "FakeSigFragment0" } })); + const events = await collect(adapter.parseStream!(sseResponse([ + frame("message_start", { message: { usage: { input_tokens: 1 } } }), + frame("content_block_start", { index: 0, content_block: { type: "thinking", thinking: "" } }), + ...fragments, + frame("content_block_stop", { index: 0 }), + frame("message_delta", { delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }), + frame("message_stop", {}), + ]), budget)); + const errors = events.filter(e => e.type === "error"); + expect(errors).toHaveLength(1); + expect((errors[0] as { code?: string }).code).toBe("translation_buffer_limit"); + expect(events[events.length - 1]?.type).toBe("error"); + expect(events.some(e => e.type === "thinking_signature" || e.type === "done")).toBe(false); + expect(budget.snapshot().currentBytes).toBe(0); + }); + + test("a completed signed block releases its retained signature bytes", async () => { + const adapter = createAnthropicAdapter(provider); + const budget = createTestTranslatorBudget({ maxTurnBytes: 4096 }); + const events = await collect(adapter.parseStream!(sseResponse([ + frame("message_start", { message: { usage: { input_tokens: 1 } } }), + frame("content_block_start", { index: 0, content_block: { type: "thinking", thinking: "" } }), + ...Array.from({ length: 20 }, () => + frame("content_block_delta", { index: 0, delta: { type: "signature_delta", signature: "FakeSigFragment0" } })), + frame("content_block_stop", { index: 0 }), + frame("message_delta", { delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }), + frame("message_stop", {}), + ]), budget)); + expect(events.filter(e => e.type === "thinking_signature")).toEqual([ + { type: "thinking_signature", signature: "FakeSigFragment0".repeat(20) }, + ]); + expect(budget.snapshot().currentBytes).toBe(0); + }); +}); diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 667beda8922..033971ca0d9 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -21,7 +21,7 @@ import { setCached, type ProviderModelDiscoveryStatus, } from "../src/codex/model-cache"; -import type { CodexCommanderConfig } from "../src/types"; +import type { CodexCommanderConfig, CodexCommanderProviderConfig } from "../src/types"; import type { NormalizedComboConfig } from "../src/combos/types"; import { enrichProviderFromRegistry } from "../src/providers/derive"; import { handleManagementAPI } from "../src/server/management-api"; @@ -2383,6 +2383,184 @@ describe("Codex catalog routed normalization", () => { expect(getJawcodeModelMetadata("anthropic", "claude-sonnet-4-6")?.contextWindow).toBe(1_000_000); }); + test("Anthropic Models API input context reaches Codex for Opus 5.5 and future live models", async () => { + globalThis.fetch = (async () => Response.json({ data: [ + { id: "claude-opus-5-5", max_input_tokens: 1_000_000, max_tokens: 128_000 }, + { id: "claude-future-model", max_input_tokens: 750_000, max_tokens: 64_000 }, + { id: "claude-smaller-model", context_length: 200_000, max_input_tokens: 1_000_000 }, + ] })) as typeof fetch; + const models = await gatherRoutedModels({ + providers: { "anthropic-apikey": { + adapter: "anthropic", baseUrl: "https://api.anthropic.com", apiKey: "sk-test", + } }, + }); + const entries = buildCatalogEntries(null, [], models); + expect(entries.find(entry => entry.slug === "anthropic-apikey/claude-opus-5-5")).toMatchObject({ + context_window: 1_000_000, max_context_window: 1_000_000, auto_compact_token_limit: 900_000, + }); + expect(entries.find(entry => entry.slug === "anthropic-apikey/claude-future-model")).toMatchObject({ + context_window: 750_000, max_context_window: 750_000, auto_compact_token_limit: 675_000, + }); + expect(entries.find(entry => entry.slug === "anthropic-apikey/claude-smaller-model")).toMatchObject({ + context_window: 200_000, max_context_window: 200_000, auto_compact_token_limit: 180_000, + }); + }); + + test("native Anthropic Opus 5.5 keeps canonical context when discovery is disabled", async () => { + globalThis.fetch = (() => { throw new Error("static models must not fetch"); }) as typeof fetch; + for (const provider of ["anthropic", "anthropic-apikey"]) { + const models = await gatherRoutedModels({ + providers: { [provider]: { + adapter: "anthropic", baseUrl: "https://api.anthropic.com", liveModels: false, + models: ["claude-opus-5-5", "claude-haiku-4-5"], + } }, + }); + const entries = buildCatalogEntries(null, [], models); + expect(entries.find(entry => entry.slug === `${provider}/claude-opus-5-5`)).toMatchObject({ + context_window: 1_000_000, max_context_window: 1_000_000, auto_compact_token_limit: 900_000, + }); + expect(entries.find(entry => entry.slug === `${provider}/claude-haiku-4-5`)?.context_window) + .toBe(200_000); + } + }); + + test("native Anthropic context fallback accepts supported Messages URL variants", async () => { + let fetchCalls = 0; + globalThis.fetch = (() => { + fetchCalls += 1; + throw new Error("static models must not fetch"); + }) as typeof fetch; + for (const provider of ["anthropic", "anthropic-apikey"]) { + for (const baseUrl of [ + "https://api.anthropic.com", + "https://api.anthropic.com/", + "https://api.anthropic.com/v1", + "https://api.anthropic.com/v1/", + "https://api.anthropic.com/v1/messages", + "https://api.anthropic.com/v1/messages/", + "https://api.anthropic.com/V1/MESSAGES///", + "https://api.anthropic.com:443/v1/messages", + ]) { + const models = await gatherRoutedModels({ providers: { [provider]: { + adapter: "anthropic", baseUrl, liveModels: false, models: ["claude-opus-5-5"], + } } }); + expect(buildCatalogEntries(null, [], models).find(entry => entry.slug === `${provider}/claude-opus-5-5`)) + .toMatchObject({ context_window: 1_000_000, max_context_window: 1_000_000, auto_compact_token_limit: 900_000 }); + } + } + expect(fetchCalls).toBe(0); + }); + + test("smaller live Opus 5.5 context outranks its native registry fallback", async () => { + const items = [ + { id: "claude-opus-5-5", max_input_tokens: 200_000 }, + { id: "claude-opus-5-5", context_length: 200_000, max_input_tokens: 1_000_000 }, + { id: "claude-opus-5-5", context_length: 200_000, max_input_tokens: 200_000 }, + ]; + const fetchedUrls: string[] = []; + for (const item of items) { + clearModelCache(); + globalThis.fetch = (async (input) => { + fetchedUrls.push(String(input)); + return Response.json({ data: [item] }); + }) as typeof fetch; + const models = await gatherRoutedModels({ + providers: { "anthropic-apikey": { + adapter: "anthropic", baseUrl: "https://api.anthropic.com", apiKey: "sk-test", + } }, + }); + expect(models.find(model => model.provider === "anthropic-apikey" && model.id === item.id)) + .toMatchObject({ contextWindow: 200_000, maxInputTokens: item.max_input_tokens }); + expect(buildCatalogEntries(null, [], models).find(entry => entry.slug === "anthropic-apikey/claude-opus-5-5")) + .toMatchObject({ context_window: 200_000, max_context_window: 200_000, auto_compact_token_limit: 180_000 }); + } + expect(fetchedUrls).toEqual(items.map(() => "https://api.anthropic.com/v1/models?limit=1000")); + }); + + test.each([ + "https://api.anthropic.com", + "https://api.anthropic.com/", + "https://api.anthropic.com/v1", + "https://api.anthropic.com/v1/", + "https://api.anthropic.com/v1/messages", + "https://api.anthropic.com/v1/messages/", + "https://api.anthropic.com/V1/MESSAGES///", + "https://api.anthropic.com:443/v1/messages", + ])("native Anthropic Messages URL alias %s discovers smaller live context through the canonical Models URL", async baseUrl => { + const fetchedUrls: string[] = []; + globalThis.fetch = (async (input) => { + const url = String(input); + fetchedUrls.push(url); + return url === "https://api.anthropic.com/v1/models?limit=1000" + ? Response.json({ data: [{ id: "claude-opus-5-5", max_input_tokens: 200_000 }] }) + : Response.json({ error: "unexpected fixture URL" }, { status: 404 }); + }) as typeof fetch; + clearModelCache(); + const models = await gatherRoutedModels({ providers: { "anthropic-apikey": { + adapter: "anthropic", baseUrl, authMode: "key", apiKey: "sk-test", + } } }); + expect(buildCatalogEntries(null, [], models).find(entry => entry.slug === "anthropic-apikey/claude-opus-5-5")) + .toMatchObject({ context_window: 200_000, max_context_window: 200_000, auto_compact_token_limit: 180_000 }); + expect(fetchedUrls).toEqual(["https://api.anthropic.com/v1/models?limit=1000"]); + }); + + test("explicit Anthropic context and input caps still lower Opus 5.5 catalog budgets", async () => { + globalThis.fetch = (async () => Response.json({ data: [ + { id: "claude-opus-5-5", max_input_tokens: 1_000_000, max_tokens: 128_000 }, + ] })) as typeof fetch; + const cases: Array<{ + provider: Partial; + providerCap?: number; + context: number; + compact: number; + }> = [ + { provider: { contextWindow: 250_000 }, context: 250_000, compact: 225_000 }, + { provider: { modelContextWindows: { "claude-opus-5-5": 250_000 } }, context: 250_000, compact: 225_000 }, + { provider: {}, providerCap: 350_000, context: 350_000, compact: 315_000 }, + { provider: { modelMaxInputTokens: { "claude-opus-5-5": 200_000 } }, context: 1_000_000, compact: 200_000 }, + { + provider: { contextWindow: 250_000, modelContextWindows: { "claude-opus-5-5": 500_000 } }, + context: 500_000, compact: 450_000, + }, + ]; + for (const liveModels of [true, false]) { + for (const item of cases) { + const models = await gatherRoutedModels({ + modelCacheTtlMs: 0, + ...(item.providerCap ? { providerContextCaps: { "anthropic-apikey": item.providerCap } } : {}), + providers: { "anthropic-apikey": { + adapter: "anthropic", baseUrl: "https://api.anthropic.com", apiKey: "sk-test", liveModels, + models: ["claude-opus-5-5"], + ...item.provider, + } }, + }); + expect(buildCatalogEntries(null, [], models).find(entry => entry.slug === "anthropic-apikey/claude-opus-5-5")) + .toMatchObject({ context_window: item.context, max_context_window: item.context, auto_compact_token_limit: item.compact }); + } + } + }); + + test("custom Anthropic endpoints do not inherit the native Opus 5.5 context", async () => { + globalThis.fetch = (async () => Response.json({ data: [ + { id: "claude-opus-5-5", max_input_tokens: 1_000_000 }, + ] })) as typeof fetch; + for (const provider of ["anthropic-apikey", "custom-anthropic"]) { + for (const liveModels of [true, false]) { + const models = await gatherRoutedModels({ + modelCacheTtlMs: 0, + providers: { [provider]: { + adapter: "anthropic", baseUrl: "https://custom-anthropic.test", apiKey: "sk-test", liveModels, + models: ["claude-opus-5-5"], + } }, + }); + expect(models.find(model => model.provider === provider && model.id === "claude-opus-5-5")?.contextWindow) + .toBeUndefined(); + expect(buildCatalogEntries(null, [], models).find(entry => entry.slug === `${provider}/claude-opus-5-5`)) + .toMatchObject({ context_window: 128_000, max_context_window: 128_000, auto_compact_token_limit: 115_200 }); + } + } + }); + test("routed entries resolve jawcode provider aliases", () => { const entries = buildCatalogEntries(nativeTemplate(), [], [ { provider: "kimi", id: "kimi-k2.5" }, diff --git a/tests/provider-model-discovery-contract.test.ts b/tests/provider-model-discovery-contract.test.ts index aaf394724ae..87c19416b3a 100644 --- a/tests/provider-model-discovery-contract.test.ts +++ b/tests/provider-model-discovery-contract.test.ts @@ -6,7 +6,7 @@ import { catalogHintsFromModelsApiItem } from "../src/codex/catalog/provider-fet import { clearModelCache, getFreshCached, setCached } from "../src/codex/model-cache"; import { buildModelsRequest } from "../src/oauth"; import { KEY_LOGIN_PROVIDERS, validateApiKey } from "../src/oauth/key-providers"; -import { deriveKeyLoginMap, providerConfigSeed } from "../src/providers/derive"; +import { deriveKeyLoginMap, enrichProviderFromRegistry, providerConfigSeed } from "../src/providers/derive"; import { extractProviderModelItems, providerModelDiscoverySpecError, @@ -15,6 +15,7 @@ import { } from "../src/providers/model-discovery"; import { PROVIDER_REGISTRY, + providerMatchesRegistryMetadataDestination, registryEntryForProviderDestination, type ProviderModelDiscoverySpec, } from "../src/providers/registry"; @@ -257,6 +258,38 @@ describe("registry-owned provider model discovery", () => { })).toEqual({}); }); + test("official Anthropic input context metadata supplies the catalog window", () => { + const source = { adapter: "anthropic", url: "https://api.anthropic.com/v1/models" }; + const item = { id: "claude-opus-5-5", max_input_tokens: 1_000_000, max_tokens: 128_000 }; + expect(catalogHintsFromModelsApiItem("anthropic", item, source)).toEqual({ + contextWindow: 1_000_000, + maxInputTokens: 1_000_000, + }); + expect(catalogHintsFromModelsApiItem("renamed-anthropic", { + ...item, + id: "claude-future-model", + context_length: 200_000, + }, source)).toEqual({ contextWindow: 200_000, maxInputTokens: 1_000_000 }); + for (const max_input_tokens of [0, -1, 0.5, Number.MAX_SAFE_INTEGER + 1]) { + expect(catalogHintsFromModelsApiItem("anthropic", { ...item, max_input_tokens }, source)) + .toEqual({}); + } + }); + + test("input ceilings do not become context windows without official Anthropic source evidence", () => { + const item = { id: "claude-opus-5-5", max_input_tokens: 1_000_000 }; + expect(catalogHintsFromModelsApiItem("anthropic", item)).toEqual({ maxInputTokens: 1_000_000 }); + for (const source of [ + { adapter: "anthropic", url: "https://relay.example/v1/models" }, + { adapter: "anthropic", url: "https://api.anthropic.com/relay/models" }, + { adapter: "anthropic", url: "https://api.anthropic.com.evil.example/v1/models" }, + { adapter: "openai-chat", url: "https://api.anthropic.com/v1/models" }, + ]) { + expect(catalogHintsFromModelsApiItem("anthropic", item, source)) + .toEqual({ maxInputTokens: 1_000_000 }); + } + }); + test("infers Codex-safe input modalities from bounded architecture metadata", () => { expect(catalogHintsFromModelsApiItem("example", { id: "vision-chat", @@ -369,6 +402,87 @@ describe("registry-owned provider model discovery", () => { }); describe("same-named custom provider preservation", () => { + test("Anthropic native URL normalization keeps key and OAuth discovery headers and custom paths", () => { + const key = buildModelsRequest({ + adapter: "anthropic", baseUrl: "https://api.anthropic.com/V1/MESSAGES///", authMode: "key", + }, "fixture-key", "anthropic-apikey"); + expect(key.url).toBe("https://api.anthropic.com/v1/models?limit=1000"); + expect(key.headers).toEqual({ "anthropic-version": "2023-06-01", "x-api-key": "fixture-key" }); + const oauth = buildModelsRequest({ + adapter: "anthropic", baseUrl: "https://api.anthropic.com/v1/messages", authMode: "oauth", + }, "fixture-token", "anthropic"); + expect(oauth.url).toBe("https://api.anthropic.com/v1/models?limit=1000"); + expect(oauth.headers["anthropic-version"]).toBe("2023-06-01"); + expect(oauth.headers.Authorization).toBe("Bearer fixture-token"); + expect(oauth.headers["anthropic-beta"]).toContain("oauth-"); + expect(oauth.headers["x-api-key"]).toBeUndefined(); + for (const baseUrl of [ + "https://custom.example/prefix/v1", + "https://custom.example/prefix/v1/messages", + "https://custom.example/prefix/V1/MESSAGES///", + "https://api.anthropic.com:8443/v1/messages", + "https://api.anthropic.com/proxy/v1/messages", + "https://api.anthropic.com/v1/messages?relay=1", + ["https://fixture-user:fixture-pass", "api.anthropic.com/v1/messages"].join("@"), + ]) { + const custom = buildModelsRequest({ adapter: "anthropic", baseUrl, authMode: "key" }, "fixture-key", "custom-anthropic"); + expect(custom.url).toBe(`${baseUrl.replace(/\/v1\/?$/, "")}/v1/models?limit=1000`); + expect(custom.headers).toEqual({ "anthropic-version": "2023-06-01", "x-api-key": "fixture-key" }); + } + }); + + test("native Anthropic context metadata does not match unrelated URL components or adapters", () => { + for (const baseUrl of [ + "http://api.anthropic.com/v1", + "https://api.anthropic.com:8443/v1/messages", + ["https://user:password", "api.anthropic.com/v1/messages"].join("@"), + "https://api.anthropic.com/v1/messages?relay=1", + "https://api.anthropic.com/v1/messages#relay", + "https://api.anthropic.com/v1/messages?", + "https://api.anthropic.com/v1/messages#", + "https://api.anthropic.com/proxy/v1/messages", + "https://api.anthropic.com/v1/custom", + "https://api.anthropic.com/v1/messages/custom", + "https://api.anthropic.com.evil.example/v1/messages", + "https://custom.example/v1/messages", + ]) { + expect(providerMatchesRegistryMetadataDestination("anthropic-apikey", { adapter: "anthropic", baseUrl })) + .toBe(false); + } + expect(providerMatchesRegistryMetadataDestination("anthropic-apikey", { + adapter: "openai-chat", baseUrl: "https://api.anthropic.com/v1/messages", + })).toBe(false); + expect(providerMatchesRegistryMetadataDestination("together", { + adapter: "openai-chat", baseUrl: "https://api.together.xyz/v1", + })).toBe(true); + expect(providerMatchesRegistryMetadataDestination("together", { + adapter: "openai-chat", baseUrl: "https://api.together.xyz/v1/messages", + })).toBe(false); + }); + + test("Anthropic metadata enrichment leaves credential and destination routing unchanged", () => { + for (const [name, authMode] of [["anthropic", "oauth"], ["anthropic-apikey", "key"]] as const) { + const configured: CodexCommanderProviderConfig = { + adapter: "anthropic", + baseUrl: "https://custom.example/anthropic", + authMode, + apiKey: "test-key", + modelContextWindows: { "claude-opus-5-5": 64_000 }, + }; + const enriched = structuredClone(configured); + enrichProviderFromRegistry(name, enriched); + expect(enriched).toMatchObject(configured); + expect(enriched.modelContextWindows).toEqual(configured.modelContextWindows); + const routed = routeModel({ providers: { [name]: enriched } }, `${name}/claude-opus-5-5`); + expect(routed.provider).toMatchObject({ + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + authMode, + apiKey: "test-key", + }); + } + }); + test("keeps an opted-in fixed key-provider collision on its configured destination and adapter", async () => { await withTogetherDiscovery({}, () => { for (const baseUrl of ["https://custom.example/anthropic", "https://api.together.xyz/v1"]) { diff --git a/tests/responses-compaction-routing.test.ts b/tests/responses-compaction-routing.test.ts index a03b9a90a3d..290331f032b 100644 --- a/tests/responses-compaction-routing.test.ts +++ b/tests/responses-compaction-routing.test.ts @@ -139,20 +139,20 @@ function sseResponse(events: Array>): Response { }); } -describe("Opus 5.5 compaction output policy through the real Anthropic adapter", () => { +describe("Opus 5.5 output policy through the real Anthropic adapter", () => { const config = keyProviderConfig({ adapter: "anthropic", baseUrl: "https://api.anthropic.com" }); type WireScalars = { model: unknown; maxTokens: unknown; thinking: unknown; effort: unknown; stream: unknown; tools: boolean }; - function stubAnthropic(stopReason = "end_turn") { + function stubAnthropic(stopReason = "end_turn", requiredOutputTokens = 45_000) { const sent: WireScalars[] = []; globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { const body = JSON.parse(String(init?.body ?? "{}")); // Retain only synthetic wire-policy scalars, never request history or headers. sent.push({ model: body.model, maxTokens: body.max_tokens, thinking: body.thinking?.type, effort: body.output_config?.effort, stream: body.stream, tools: body.tools !== undefined }); - // A compact synthetic workload needs 45000 total generated tokens; no large - // generated text is needed to expose the former 40192-token truncation. - const outputTokens = Math.min(body.max_tokens, 45_000); - const effectiveStop = body.max_tokens < 45_000 ? "max_tokens" : stopReason; + // Model the shared thinking/output total with a tiny synthetic response. The + // upstream terminal is determined by whether this workload fits the wire cap. + const outputTokens = Math.min(body.max_tokens, requiredOutputTokens); + const effectiveStop = body.max_tokens < requiredOutputTokens ? "max_tokens" : stopReason; if (!body.stream) return jsonResponse({ content: [{ type: "text", text: "Progress and next steps." }], stop_reason: effectiveStop, usage: { input_tokens: 20, output_tokens: outputTokens }, @@ -184,6 +184,43 @@ describe("Opus 5.5 compaction output policy through the real Anthropic adapter", return terminals[0].response as { status: string; output: Array<{ type: string; encrypted_content: string }>; incomplete_details?: { reason: string } }; } + function ordinaryBody(extra: Record = {}) { + return body({ + input: [{ type: "message", role: "user", content: "Continue implementing and checking the remaining modules." }], + ...extra, + }); + } + + test.each([false, true])("ordinary 60000-token workload completes once with omitted total (stream=%s)", async stream => { + const sent = stubAnthropic("end_turn", 60_000); + const logCtx: RequestLogContext = { model: "", provider: "" }; + const response = await handleResponses(compactionRequest(ordinaryBody({ stream })), 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([ + [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) => { + 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); + 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"); + expect(result.incomplete_details?.reason).toBe("max_output_tokens"); + expect(result.output.some(item => item.type === "compaction")).toBe(false); + expect(sent).toEqual([{ model: "claude-opus-5-5", maxTokens: maxOutputTokens, thinking: "adaptive", + effort: "max", stream, tools: true }]); + expect(logCtx.usage?.outputTokens).toBe(maxOutputTokens); + }); + 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: "" }; @@ -224,7 +261,7 @@ describe("Opus 5.5 compaction output policy through the real Anthropic adapter", { type: "message", role: "user", content: "Continue." }], })), config, { model: "", provider: "" })).text(); expect(sent).toHaveLength(1); - expect(sent[0]).toMatchObject({ maxTokens: 40_192, effort: "max" }); + expect(sent[0]).toMatchObject({ maxTokens: 128_000, effort: "max" }); }); test.each(["v1", "v2"])("%s max_tokens keeps partial summaries out of replacement history without retrying", async version => { diff --git a/tests/terminal-guard.test.ts b/tests/terminal-guard.test.ts index 0c4c2398c4e..17bf9d100fa 100644 --- a/tests/terminal-guard.test.ts +++ b/tests/terminal-guard.test.ts @@ -5,8 +5,54 @@ import { guardTerminalEventStream, } from "../src/server/responses/terminal-guard"; import { buildResponseJSON } from "../src/bridge"; +import { createAnthropicAdapter } from "../src/adapters/anthropic"; +import { withTestTranslatorBudget } from "./helpers/translator-budget"; import type { AdapterEvent, CodexCommanderParsedRequest } from "../src/types"; +function anthropicThinkingTurn(withTool = false): Response { + const frame = (type: string, data: Record) => + `event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`; + const frames = [frame("message_start", { message: { usage: { input_tokens: 10 } } })]; + let index = 0; + for (const block of [ + { type: "redacted_thinking", data: "FAKE_REDACTED_BEFORE" }, + { type: "thinking", thinking: "first signed chain", signature: "FakeFirstSignature123456==" }, + { type: "redacted_thinking", data: "FAKE_REDACTED_MIDDLE" }, + { type: "thinking", thinking: "", signature: "FakeEmptySignature123456==" }, + { type: "thinking", thinking: "second signed chain", signature: "FakeSecondSignature123456==" }, + { type: "redacted_thinking", data: "FAKE_REDACTED_AFTER" }, + ]) { + frames.push(frame("content_block_start", { + index, content_block: block.type === "thinking" ? { type: "thinking", thinking: "" } : block, + })); + if (block.type === "thinking") { + if (block.thinking) frames.push(frame("content_block_delta", { + index, delta: { type: "thinking_delta", thinking: block.thinking }, + })); + // Real streams fragment signatures; the adapter closes each complete signature at stop. + for (const signature of [block.signature!.slice(0, 7), block.signature!.slice(7)]) { + frames.push(frame("content_block_delta", { index, delta: { type: "signature_delta", signature } })); + } + } + frames.push(frame("content_block_stop", { index })); + index += 1; + } + frames.push(frame("content_block_start", { index, content_block: { type: "text", text: "" } })); + frames.push(frame("content_block_delta", { index, delta: { type: "text_delta", text: "I'll fix the parser." } })); + frames.push(frame("content_block_stop", { index })); + if (withTool) { + index += 1; + frames.push(frame("content_block_start", { + index, content_block: { type: "tool_use", id: "toolu_fake", name: "exec_command", input: {} }, + })); + frames.push(frame("content_block_delta", { index, delta: { type: "input_json_delta", partial_json: "{}" } })); + frames.push(frame("content_block_stop", { index })); + } + frames.push(frame("message_delta", { delta: { stop_reason: withTool ? "tool_use" : "end_turn" }, usage: { output_tokens: 2 } })); + frames.push(frame("message_stop", {})); + return new Response(frames.join(""), { headers: { "content-type": "text/event-stream" } }); +} + function parsed(userText: string, withTools = true): CodexCommanderParsedRequest { return { modelId: "se-claude-opus-4.8", @@ -161,6 +207,82 @@ describe("terminal guard", () => { expect(next.context.messages[2]).toMatchObject({ role: "developer" }); }); + test("replays native signed blocks, empty signed blocks and redactions intact in the no-tool continuation", async () => { + const adapter = withTestTranslatorBudget(createAnthropicAdapter({ + adapter: "anthropic", baseUrl: "https://example.test", apiKey: "sk-test", + })); + const original = parsed("Fix the parser"); + original.modelId = "claude-opus-5-5"; + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(anthropicThinkingTurn())) events.push(event); + expect(analyzeTerminalTurn(original, events).decision).toBe("continue"); + + const expectedContent = [ + { type: "redacted_thinking", data: "FAKE_REDACTED_BEFORE" }, + { type: "thinking", thinking: "first signed chain", signature: "FakeFirstSignature123456==" }, + { type: "redacted_thinking", data: "FAKE_REDACTED_MIDDLE" }, + { type: "thinking", thinking: "", signature: "FakeEmptySignature123456==" }, + { type: "thinking", thinking: "second signed chain", signature: "FakeSecondSignature123456==" }, + { type: "redacted_thinking", data: "FAKE_REDACTED_AFTER" }, + { type: "text", text: "I'll fix the parser." }, + ]; + const assistantContent = async (next: CodexCommanderParsedRequest) => { + const request = await adapter.buildRequest(next) as { body: string }; + const body = JSON.parse(request.body) as { messages: Array<{ role: string; content: unknown }> }; + return body.messages.find(message => message.role === "assistant")?.content; + }; + expect(await assistantContent(buildContinuationRequest(original, events))).toEqual(expectedContent); + expect(original.context.messages).toHaveLength(1); + + let continuations = 0; + const output: AdapterEvent[] = []; + for await (const event of guardTerminalEventStream({ + parsed: original, + firstEvents: (async function* () { yield* events; })(), + adapterName: "anthropic", + continuation: async next => { + continuations += 1; + expect(await assistantContent(next)).toEqual(expectedContent); + return (async function* (): AsyncGenerator { + yield { type: "tool_call_start", id: "toolu_next", name: "exec_command" }; + yield { type: "tool_call_delta", arguments: "{}" }; + yield { type: "tool_call_end" }; + yield { type: "done" }; + })(); + }, + })) output.push(event); + expect(continuations).toBe(1); + expect(output.filter(event => event.type === "assistant_boundary")).toHaveLength(1); + expect(output.filter(event => event.type === "thinking_signature")).toEqual(events.filter(event => event.type === "thinking_signature")); + expect(output.filter(event => event.type === "redacted_thinking")).toEqual(events.filter(event => event.type === "redacted_thinking")); + expect(output.filter(event => event.type === "done")).toHaveLength(1); + expect(output.some(event => event.type === "tool_call_start")).toBe(true); + }); + + test("native signed thinking does not activate the guard without available tools or after a tool call", async () => { + for (const withTool of [false, true]) { + const adapter = withTestTranslatorBudget(createAnthropicAdapter({ + adapter: "anthropic", baseUrl: "https://example.test", apiKey: "sk-test", + })); + let continuations = 0; + const output: AdapterEvent[] = []; + for await (const event of guardTerminalEventStream({ + parsed: parsed("Fix the parser", withTool), + firstEvents: adapter.parseStream(anthropicThinkingTurn(withTool)), + adapterName: "anthropic", + continuation: () => { + continuations += 1; + return (async function* (): AsyncGenerator { yield { type: "done" }; })(); + }, + })) output.push(event); + expect(continuations).toBe(0); + expect(output.some(event => event.type === "assistant_boundary")).toBe(false); + expect(output.filter(event => event.type === "thinking_signature")).toHaveLength(3); + expect(output.filter(event => event.type === "done")).toHaveLength(1); + expect(output.some(event => event.type === "tool_call_start")).toBe(withTool); + } + }); + test("can guard a fetch-based adapter stream with a continuation callback", async () => { let continuations = 0; const actual: AdapterEvent[] = []; From 38b11ccf9d4462342b6e0da6618749086c62a746 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Sat, 24 Oct 2026 00:20:38 -0400 Subject: [PATCH 2/2] chore(release): prepare 0.1.25 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0fcf1649f68..f67fbad7d01 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codexcommander", - "version": "0.1.24", + "version": "0.1.25", "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",