Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions src/adapters/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -895,11 +895,20 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
enforceAnthropicImageLimits(messages);
const tools = toolsToAnthropicFormat(parsed, toolNames);

// Codex never sends `max_output_tokens`, so the omitted-limit default decides how
// long a Claude answer may run. Honor the provider's configured output budget
// (`modelMaxOutputTokens` / `defaultMaxOutputTokens`) before falling back to the
// conservative 8192, which truncates long answers with stop_reason=max_tokens.
const configuredMaxOut = modelRecordValue(provider.modelMaxOutputTokens, parsed.modelId)
?? provider.defaultMaxOutputTokens;
const omittedMaxTokens = typeof configuredMaxOut === "number" && configuredMaxOut > 0
? configuredMaxOut
: DEFAULT_MAX_TOKENS;
const body: Record<string, unknown> = {
model: parsed.modelId,
messages,
stream: parsed.stream,
max_tokens: parsed.options.maxOutputTokens ?? DEFAULT_MAX_TOKENS,
max_tokens: parsed.options.maxOutputTokens ?? omittedMaxTokens,
};
if (isOAuth) {
// Claude OAuth (Pro/Max) requires the first system block to be the Claude Code identity.
Expand Down Expand Up @@ -942,13 +951,13 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
// so effort=max (budget=32k) still leaves OUTPUT_HEADROOM tokens for visible output.
body.max_tokens = explicitMaxOut !== undefined
? explicitMaxOut
: Math.min(ADAPTIVE_THINKING_CEILING, Math.max(DEFAULT_MAX_TOKENS, floor));
: Math.max(omittedMaxTokens, Math.min(ADAPTIVE_THINKING_CEILING, Math.max(DEFAULT_MAX_TOKENS, floor)));
} else {
// Anthropic requires max_tokens > thinking.budget_tokens (max_tokens caps thinking +
// visible output) and budget_tokens >= 1024. Codex sends the SAME value for both, which
// 400s ("max_tokens must be greater than thinking.budget_tokens"). Size them so max_tokens
// always exceeds the budget within a model-safe ceiling, reserving room for visible output.
const maxOut = parsed.options.maxOutputTokens ?? DEFAULT_MAX_TOKENS;
const maxOut = parsed.options.maxOutputTokens ?? omittedMaxTokens;
const wantBudget = reasoningBudget(effectiveReasoning);
const maxTokens = Math.min(REASONING_MAX_TOKENS_CEILING, Math.max(maxOut, wantBudget + OUTPUT_HEADROOM));
const budget = Math.max(MIN_THINKING_BUDGET, Math.min(wantBudget, maxTokens - OUTPUT_FLOOR));
Expand Down
56 changes: 54 additions & 2 deletions src/codex/catalog/provider-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import type { OcxConfig, OcxProviderConfig } from "../../types";
import { modelInList } from "../../types";
import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
import { isModelVisionSidecarConsumer } from "../../vision/eligibility";
import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata";
import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider, type ModelMetadata } from "../../generated/model-metadata";
import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive";
import {
captureFastPolicyAuthority,
Expand Down Expand Up @@ -862,6 +862,57 @@ interface ComboCatalogMemberFallback {
readonly reasoningEfforts?: readonly string[];
}

/**
* Ladder advertised for a combo member whose vendor metadata says it reasons but
* carries no explicit ladder (Claude, Grok). Codex needs a non-empty ladder to show
* the effort control; the routed adapters clamp to the real upstream top rung.
*/
const ROUTED_COMBO_MEMBER_REASONING_EFFORTS: readonly string[] = ["low", "medium", "high", "xhigh", "max"];

/**
* Vendor-table lookup tolerant of point releases and date pins. Configured combo
* targets often name a variant the table does not carry (`claude-fable-5-1`,
* `claude-opus-4-5-20251101`); the base family row still describes its modality
* and reasoning capability, so fall back to it before giving up.
*/
function comboMemberVendorMetadata(provider: string, modelId: string): ModelMetadata | undefined {
const exact = getModelMetadataCaseInsensitive(provider, modelId);
if (exact) return exact;
let candidate = modelId.replace(/\[[^\]]*\]$/, "");
while (true) {
const trimmed = candidate.replace(/-\d+$/, "");
if (trimmed === candidate || !trimmed.includes("-")) return undefined;
const hit = getModelMetadataCaseInsensitive(provider, trimmed);
if (hit) return hit;
candidate = trimmed;
}
}

/**
* Combo members are usually thin discovery rows (id + context window). Without a
* capability source the combo intersection collapses to text-only / no effort ladder,
* and the Codex app then refuses image attachments and hides the effort picker for
* every Claude combo. The generated vendor table knows both, so use it as the
* last-resort fallback when the caller supplied none.
*/
function vendorMetadataComboFallback(target: { provider: string; model: string }): ComboCatalogMemberFallback | undefined {
const metadataProvider = resolveMetadataProvider(target.provider);
const metadata = metadataProvider ? comboMemberVendorMetadata(metadataProvider, target.model) : undefined;
if (!metadata) return undefined;
return {
...(typeof metadata.contextWindow === "number" && metadata.contextWindow > 0
? { contextWindow: metadata.contextWindow }
: {}),
...(typeof metadata.maxTokens === "number" && metadata.maxTokens > 0
? { maxInputTokens: metadata.maxTokens }
: {}),
...(Array.isArray(metadata.input) && metadata.input.length > 0
? { inputModalities: [...metadata.input] }
: {}),
...(metadata.reasoning === true ? { reasoningEfforts: [...ROUTED_COMBO_MEMBER_REASONING_EFFORTS] } : {}),
};
}

/**
* Resolve a combo target to a catalog member for derivation.
* Prefer discovery metadata; when the target is missing from the gather map or
Expand All @@ -877,11 +928,12 @@ export function resolveComboCatalogMember(
memberByKey: ReadonlyMap<string, CatalogModel>,
providers: ReadonlyMap<string, OcxProviderConfig>,
contextCap?: number,
fallback?: ComboCatalogMemberFallback,
callerFallback?: ComboCatalogMemberFallback,
metadataModelIdCaseFold?: boolean,
): CatalogModel | undefined {
const existing = memberByKey.get(targetKey(target));
const prov = providers.get(target.provider);
const fallback = callerFallback ?? vendorMetadataComboFallback(target);
// Disabled providers never contribute members — even a complete discovery row
// is unusable for catalog derivation while the provider is off.
if (prov?.disabled === true) return undefined;
Expand Down
8 changes: 8 additions & 0 deletions src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,10 @@ export type ProviderConfigSeed = Pick<
// always on, per the official models overview and pricing page (platform.claude.com).
const ANTHROPIC_MODELS = ["claude-fable-5-1", "claude-fable-5", "claude-sonnet-5", "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"];
const ANTHROPIC_MODEL_CONTEXT_WINDOWS: Record<string, number> = { "claude-fable-5-1": 1_000_000, "claude-sonnet-5": 1_000_000, "claude-fable-5": 1_000_000, "claude-opus-5": 1_000_000, "claude-opus-4-8": 1_000_000, "claude-opus-4-7": 1_000_000, "claude-opus-4-6": 1_000_000, "claude-sonnet-4-6": 1_000_000, "claude-haiku-4-5": 200_000 };
// Every current Claude family accepts at least 64k output tokens (Haiku 4.5 / Sonnet 4.x
// through Opus 5 and Fable 5). Anthropic caps max_tokens per model server-side, so a
// larger request never over-allocates; it only stops the 8192 truncation.
const ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS = 64_000;

// 260814 GLM-5.3 is registered pre-emptively alongside 5.2 everywhere 5.2 appears. Z.AI's
// devpack "How to Switch Models" page (docs.z.ai/devpack/latest-model) lists glm-5.3 and
Expand Down Expand Up @@ -1314,6 +1318,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
note: "Log in with your Claude account",
models: [...ANTHROPIC_MODELS],
modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS },
// Codex omits max_output_tokens; without a provider budget the Anthropic adapter
// falls back to 8192, which truncates long answers with stop_reason=max_tokens.
defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS,
defaultModel: "claude-sonnet-5",
},
{
Expand All @@ -1330,6 +1337,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
models: [...ANTHROPIC_MODELS],
liveModels: true,
modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS },
defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS,
defaultModel: "claude-sonnet-5",
},
{
Expand Down
14 changes: 14 additions & 0 deletions tests/anthropic-reasoning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,20 @@ describe("anthropic extended-thinking gate", () => {
expect(b.max_tokens as number).toBe(64000);
});

test("configured provider output budget replaces the 8192 default when the caller omits max_output_tokens", async () => {
const budgeted = { ...provider, defaultMaxOutputTokens: 64_000, modelMaxOutputTokens: { "claude-fable-5": 32_000 } };
// No reasoning: the configured budget is the wire max_tokens.
expect((await bodyOf(parsed("none", {}, "claude-opus-5"), budgeted)).max_tokens).toBe(64_000);
expect((await bodyOf(parsed("none", {}, "claude-fable-5"), budgeted)).max_tokens).toBe(32_000);
// Adaptive thinking: the budget still wins over the headroom-derived ceiling.
expect((await bodyOf(parsed("max", {}, "claude-opus-5"), budgeted)).max_tokens).toBe(64_000);
// Budget thinking on an older family keeps max_tokens above the thinking budget.
const legacy = await bodyOf(parsed("high", {}, "claude-haiku-4-5"), budgeted);
expect(legacy.max_tokens as number).toBeGreaterThan((legacy.thinking as { budget_tokens: number }).budget_tokens);
// An explicit caller limit still wins over the configured budget.
expect((await bodyOf(parsed("none", { maxOutputTokens: 512 }, "claude-opus-5"), budgeted)).max_tokens).toBe(512);
});

test.each([
["high", 24_576],
["xhigh", 32_768],
Expand Down
40 changes: 40 additions & 0 deletions tests/codex-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1551,6 +1551,46 @@ describe("combo catalog capability intersection", () => {
)).toBeUndefined();
});

test("resolveComboCatalogMember restores vendor image and effort capabilities for thin Claude rows", () => {
const providers = new Map([["anthropic", {
adapter: "anthropic" as const,
baseUrl: "https://api.anthropic.com",
}]]);
// A discovery row that only carries id + window (the live Anthropic /models shape).
expect(resolveComboCatalogMember(
{ provider: "anthropic", model: "claude-opus-5" },
new Map([["anthropic/claude-opus-5", { provider: "anthropic", id: "claude-opus-5", contextWindow: 1_000_000 }]]),
providers,
)).toMatchObject({
contextWindow: 1_000_000,
inputModalities: ["text", "image"],
reasoningEfforts: ["low", "medium", "high", "xhigh", "max"],
});
// Point-release ids fall back to their family row in the vendor table.
expect(resolveComboCatalogMember(
{ provider: "anthropic", model: "claude-fable-5-1" },
new Map([["anthropic/claude-fable-5-1", { provider: "anthropic", id: "claude-fable-5-1", contextWindow: 1_000_000 }]]),
providers,
)).toMatchObject({
inputModalities: ["text", "image"],
reasoningEfforts: ["low", "medium", "high", "xhigh", "max"],
});
// An explicit caller fallback still wins over the vendor table.
expect(resolveComboCatalogMember(
{ provider: "anthropic", model: "claude-opus-5" },
new Map([["anthropic/claude-opus-5", { provider: "anthropic", id: "claude-opus-5", contextWindow: 1_000_000 }]]),
providers,
undefined,
{ inputModalities: ["text"], reasoningEfforts: [] },
)).toMatchObject({ inputModalities: ["text"], reasoningEfforts: [] });
// Unknown ids keep their unknown ladder rather than inventing one.
expect(resolveComboCatalogMember(
{ provider: "a", model: "ghost" },
new Map(),
new Map([["a", { adapter: "openai-chat" as const, baseUrl: "https://a.example/v1" }]]),
)).not.toHaveProperty("reasoningEfforts");
});

test("still omits combos when synthesis cannot recover hard failures", async () => {
const config: OcxConfig = {
port: 10100,
Expand Down
Loading