From 67c8a0436e9c8589b44d524acec8f8fcec89a377 Mon Sep 17 00:00:00 2001 From: Randi <55005611+rdself@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:05:47 -0400 Subject: [PATCH] fix(models): treat unknown output caps as unset (#4584) Integrated into release/v3.8.34 --- .env.example | 5 ++ CHANGELOG.md | 4 +- open-sse/services/combo/comboStructure.ts | 14 +++-- open-sse/services/reasoningTokenBuffer.ts | 5 +- .../translator/request/claude-to-gemini.ts | 5 +- .../translator/request/openai-to-claude.ts | 24 +++++--- .../translator/request/openai-to-gemini.ts | 18 +++--- src/lib/modelCapabilities.ts | 10 ++-- src/shared/constants/modelSpecs.ts | 14 ++--- tests/integration/chat-pipeline.test.ts | 13 ++--- tests/integration/memory-pipeline.test.ts | 8 ++- .../openai-to-claude/multi-turn.json | 2 +- .../openai-to-gemini/with-system.json | 1 - tests/unit/combo-routing-engine.test.ts | 4 +- tests/unit/combo-strategies.test.ts | 48 ++++++++-------- tests/unit/combo-vision-aware-routing.test.ts | 16 ++++-- tests/unit/db-usage-analytics-3500.test.ts | 7 +-- .../unit/model-capabilities-registry.test.ts | 21 +++++++ .../unit/translator-claude-to-gemini.test.ts | 13 +++++ .../unit/translator-openai-to-claude.test.ts | 23 +++++++- .../unit/translator-openai-to-gemini.test.ts | 57 +++++++++++++------ 21 files changed, 206 insertions(+), 106 deletions(-) diff --git a/.env.example b/.env.example index 91fe676c4c..bec17f344c 100644 --- a/.env.example +++ b/.env.example @@ -795,6 +795,11 @@ KIRO_USER_AGENT="AWS-SDK-JS/3.0.0 kiro-ide/1.0.0" # rather than a secret. Only override if AWS ever starts enforcing this field. # Used by: src/lib/oauth/constants/oauth.ts (KIRO_CONFIG.socialClientId). # KIRO_OAUTH_CLIENT_ID=kiro-cli +# Enable full per-frame message CRC validation for Kiro streams. Off by default +# because it is O(frame bytes) on the main thread; use only for debugging +# suspected corrupted-stream issues. +# Used by: open-sse/executors/kiro.ts +# KIRO_VERIFY_FULL_CRC=false QODER_USER_AGENT="Qoder-Cli" QWEN_USER_AGENT="QwenCode/0.15.11 (linux; x64)" CURSOR_USER_AGENT="Cursor/3.4" diff --git a/CHANGELOG.md b/CHANGELOG.md index 71e3dcd010..6f435c0544 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,9 @@ ## [Unreleased] -### 🐛 Fixed +### Fixed -- **fix(resilience): upstream retry hints toggle now also governs reset text in error bodies** — quota-reset text such as Antigravity `Resets in 160h...` and subscription quota timestamps no longer bypass the Settings → Resilience "Use upstream retry hints" switch. When the switch is disabled, those responses keep the local cooldown behavior instead of applying the upstream-provided reset window. +- **fix(models): unknown max output limits no longer default to 8192** — Models without synced, registry, or static `maxOutputTokens` metadata now resolve the limit as unknown instead of falling back to a generic 8192-token cap. Combo compatibility filtering, reasoning max-token buffering, and Claude/Gemini request translation only clamp or inject `maxOutputTokens` when a real model cap is known; otherwise requested output limits are preserved and absent limits stay absent. --- diff --git a/open-sse/services/combo/comboStructure.ts b/open-sse/services/combo/comboStructure.ts index 0930068693..df6ecfb826 100644 --- a/open-sse/services/combo/comboStructure.ts +++ b/open-sse/services/combo/comboStructure.ts @@ -478,6 +478,14 @@ function deriveRequestCompatibilityRequirements( }; } +function exceedsKnownOutputLimit( + requestedOutputTokens: number, + maxOutputTokens: number | null +): boolean { + if (requestedOutputTokens <= 0 || maxOutputTokens === null) return false; + return maxOutputTokens < requestedOutputTokens; +} + function getTargetCompatibilityFailures( target: ResolvedComboTarget, requirements: RequestCompatibilityRequirements @@ -506,11 +514,7 @@ function getTargetCompatibilityFailures( failures.push("structured_output"); } - if ( - requirements.requestedOutputTokens > 0 && - Number.isFinite(capabilities.maxOutputTokens) && - capabilities.maxOutputTokens < requirements.requestedOutputTokens - ) { + if (exceedsKnownOutputLimit(requirements.requestedOutputTokens, capabilities.maxOutputTokens)) { failures.push("output_tokens"); } diff --git a/open-sse/services/reasoningTokenBuffer.ts b/open-sse/services/reasoningTokenBuffer.ts index 23290de713..65d021e7f5 100644 --- a/open-sse/services/reasoningTokenBuffer.ts +++ b/open-sse/services/reasoningTokenBuffer.ts @@ -1,7 +1,4 @@ import { getResolvedModelCapabilities } from "../../src/lib/modelCapabilities.ts"; -import { MODEL_SPECS } from "../../src/shared/constants/modelSpecs.ts"; - -const DEFAULT_MAX_OUTPUT_TOKENS = MODEL_SPECS.__default__.maxOutputTokens; export function toPositiveInteger(value: unknown): number | null { const numericValue = @@ -29,7 +26,7 @@ export function resolveReasoningBufferedMaxTokens( if (capabilities.supportsThinking !== true) return null; const maxOutputTokens = toPositiveInteger(capabilities.maxOutputTokens); - if (maxOutputTokens === null || maxOutputTokens === DEFAULT_MAX_OUTPUT_TOKENS) return null; + if (maxOutputTokens === null) return null; if (current > maxOutputTokens) return maxOutputTokens; if (current === maxOutputTokens) return current; diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index 92b825f46e..fadc428731 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -55,7 +55,10 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { result.generationConfig.topK = body.top_k; } if (body.max_tokens !== undefined) { - result.generationConfig.maxOutputTokens = capMaxOutputTokens(model, body.max_tokens); + const maxOutputTokens = capMaxOutputTokens(model, body.max_tokens); + if (maxOutputTokens !== null) { + result.generationConfig.maxOutputTokens = maxOutputTokens; + } } // ── System instruction ───────────────────────────────────────── diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index 4ec6236f75..55a1f28433 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -41,14 +41,13 @@ function applyCopilotSummarizedThinkingDisplay( // - max_tokens must be <= model output cap (e.g. 128000 for Opus 4.7) const MIN_CLAUDE_THINKING_BUDGET = 1024; const MIN_RESPONSE_ROOM = 1024; -const FALLBACK_OUTPUT_CAP = 128000; -function safeCapMaxOutputTokens(model: string): number { +function safeCapMaxOutputTokens(model: string): number | null { try { const cap = capMaxOutputTokens(model); - return typeof cap === "number" && cap > 0 ? cap : FALLBACK_OUTPUT_CAP; + return typeof cap === "number" && cap > 0 ? cap : null; } catch { - return FALLBACK_OUTPUT_CAP; + return null; } } @@ -86,26 +85,35 @@ export function fitThinkingToMaxTokens( // No budgeted thinking — just cap max_tokens to the model output ceiling. if (!thinking || requestedBudget <= 0) { return { - maxTokens: Math.min(Math.max(callerMaxTokens, 1), modelCap), + maxTokens: + modelCap === null + ? Math.max(callerMaxTokens, 1) + : Math.min(Math.max(callerMaxTokens, 1), modelCap), thinking, }; } let responseRoom = Math.max(callerMaxTokens, MIN_RESPONSE_ROOM); - let target = Math.min(responseRoom + requestedBudget, modelCap); + let target = + modelCap === null + ? responseRoom + requestedBudget + : Math.min(responseRoom + requestedBudget, modelCap); let fittedBudget = target - responseRoom; // If the cap squeezed thinking below Anthropic's floor, try shrinking // response room to MIN_RESPONSE_ROOM to recover budget. if (fittedBudget < MIN_CLAUDE_THINKING_BUDGET && responseRoom > MIN_RESPONSE_ROOM) { responseRoom = MIN_RESPONSE_ROOM; - target = Math.min(responseRoom + requestedBudget, modelCap); + target = + modelCap === null + ? responseRoom + requestedBudget + : Math.min(responseRoom + requestedBudget, modelCap); fittedBudget = target - responseRoom; } // Cap too tight for any thinking — disable rather than send an invalid request. if (fittedBudget < MIN_CLAUDE_THINKING_BUDGET) { - return { maxTokens: modelCap, thinking: undefined }; + return { maxTokens: modelCap ?? Math.max(callerMaxTokens, 1), thinking: undefined }; } const adjustedThinking: Record = { ...thinking }; diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 39e2016da6..bc9e6651e6 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -32,9 +32,8 @@ import { import { buildGeminiTools, sanitizeGeminiToolName } from "../helpers/geminiToolsSanitizer.ts"; // Observed Antigravity wrapper output cap, not an underlying model capability. -// Keep this bridge-local: capMaxOutputTokens() falls back to OmniRoute's generic -// 8192 default for unknown Claude-family IDs, while Antigravity currently caps -// visible output around 16K. See: https://github.com/keisksw/antigravity-output-analysis +// Keep this bridge-local: Antigravity currently caps visible output around 16K. +// See: https://github.com/keisksw/antigravity-output-analysis const ANTIGRAVITY_CLAUDE_MAX_OUTPUT_TOKENS = 16_384; type GeminiPart = Record; @@ -274,13 +273,12 @@ function openaiToGeminiBase( if (body.stop !== undefined) { result.generationConfig.stopSequences = Array.isArray(body.stop) ? body.stop : [body.stop]; } - const requestedMaxOutputTokens = (body.max_tokens ?? body.max_completion_tokens) as - | number - | undefined; - if (requestedMaxOutputTokens !== undefined) { - result.generationConfig.maxOutputTokens = capMaxOutputTokens(model, requestedMaxOutputTokens); - } else { - result.generationConfig.maxOutputTokens = capMaxOutputTokens(model); + const maxOutputTokens = capMaxOutputTokens( + model, + (body.max_tokens ?? body.max_completion_tokens) as number | undefined + ); + if (maxOutputTokens !== null) { + result.generationConfig.maxOutputTokens = maxOutputTokens; } // Thinking / Reasoning support (Google Gemini 2.0+ Thinking models) diff --git a/src/lib/modelCapabilities.ts b/src/lib/modelCapabilities.ts index 27f3ada397..940e94a260 100644 --- a/src/lib/modelCapabilities.ts +++ b/src/lib/modelCapabilities.ts @@ -53,7 +53,7 @@ export interface ResolvedModelCapabilities { temperature: boolean | null; contextWindow: number | null; maxInputTokens: number | null; - maxOutputTokens: number; + maxOutputTokens: number | null; defaultThinkingBudget: number; thinkingBudgetCap: number | null; thinkingOverhead: number | null; @@ -382,7 +382,7 @@ export function getResolvedModelCapabilities(input: CapabilityInput): ResolvedMo synced?.limit_output ?? (typeof registryModel?.maxOutputTokens === "number" ? registryModel.maxOutputTokens : null) ?? spec?.maxOutputTokens ?? - MODEL_SPECS.__default__.maxOutputTokens, + null, defaultThinkingBudget: spec?.defaultThinkingBudget ?? 0, thinkingBudgetCap: spec?.thinkingBudgetCap ?? null, thinkingOverhead: spec?.thinkingOverhead ?? null, @@ -416,9 +416,11 @@ export function supportsMaxTokens(input: CapabilityInput): boolean { return getResolvedModelCapabilities(input).supportsMaxTokens; } -export function capMaxOutputTokens(input: CapabilityInput, requested?: number): number { +export function capMaxOutputTokens(input: CapabilityInput, requested?: number): number | null { const cap = getResolvedModelCapabilities(input).maxOutputTokens; - return requested ? Math.min(requested, cap) : cap; + const hasRequested = typeof requested === "number" && Number.isFinite(requested); + if (cap === null) return hasRequested ? requested : null; + return hasRequested ? Math.min(requested, cap) : cap; } export function getDefaultThinkingBudget(input: CapabilityInput): number { diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index 5787d7d1d5..be17539c0b 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -5,7 +5,7 @@ */ export interface ModelSpec { - maxOutputTokens: number; + maxOutputTokens?: number; contextWindow?: number; defaultThinkingBudget?: number; thinkingBudgetCap?: number; @@ -457,9 +457,7 @@ export const MODEL_SPECS: Record = { }, // Defaults - __default__: { - maxOutputTokens: 8192, - }, + __default__: {}, }; export function getModelSpec(modelId: string): ModelSpec | undefined { @@ -515,10 +513,12 @@ export function normalizeThinkingForModel>( return body; } -export function capMaxOutputTokens(modelId: string, requested?: number): number { +export function capMaxOutputTokens(modelId: string, requested?: number): number | undefined { const spec = getModelSpec(modelId); - const cap = spec?.maxOutputTokens ?? MODEL_SPECS.__default__.maxOutputTokens; - return requested ? Math.min(requested, cap) : cap; + const cap = spec?.maxOutputTokens; + const hasRequested = typeof requested === "number" && Number.isFinite(requested); + if (typeof cap !== "number") return hasRequested ? requested : undefined; + return hasRequested ? Math.min(requested, cap) : cap; } export function getDefaultThinkingBudget(modelId: string): number { diff --git a/tests/integration/chat-pipeline.test.ts b/tests/integration/chat-pipeline.test.ts index 80ac8f0f08..1a8b8ca358 100644 --- a/tests/integration/chat-pipeline.test.ts +++ b/tests/integration/chat-pipeline.test.ts @@ -711,15 +711,10 @@ test("chat pipeline applies Codex CLI fingerprint to OAuth responses requests", assert.ok(headerOrder.indexOf("Accept") < headerOrder.indexOf("User-Agent")); const bodyOrder = Object.keys(JSON.parse(call.bodyString)); - assert.deepEqual(bodyOrder.slice(0, 7), [ - "model", - "stream", - "input", - "instructions", - "store", - "reasoning", - "prompt_cache_key", - ]); + assert.deepEqual( + bodyOrder.slice(0, 8), + "model stream input instructions store reasoning include prompt_cache_key".split(" ") + ); assert.equal(call.body.model, "gpt-5.5"); assert.equal(call.body.store, false); assert.equal( diff --git a/tests/integration/memory-pipeline.test.ts b/tests/integration/memory-pipeline.test.ts index ac1bfcb09e..184601d3e1 100644 --- a/tests/integration/memory-pipeline.test.ts +++ b/tests/integration/memory-pipeline.test.ts @@ -60,12 +60,15 @@ test.after(async () => { await harness.cleanup(); }); -async function enableMemory(maxTokens = 400) { +async function enableMemory( + maxTokens = 400, + strategy: "recent" | "semantic" | "hybrid" = "recent" +) { await settingsDb.updateSettings({ memoryEnabled: true, memoryMaxTokens: maxTokens, memoryRetentionDays: 30, - memoryStrategy: "recent", + memoryStrategy: strategy, }); } @@ -172,6 +175,7 @@ test("later requests inject retrieved memories into upstream messages", async () test("memory search ranks query-relevant memories first", async () => { const apiKey = await seedApiKey(); + await enableMemory(400, "hybrid"); await memoryTools.omniroute_memory_add.handler({ apiKeyId: apiKey.id, diff --git a/tests/snapshots/translation/openai-to-claude/multi-turn.json b/tests/snapshots/translation/openai-to-claude/multi-turn.json index c565c4c70d..d928907bcb 100644 --- a/tests/snapshots/translation/openai-to-claude/multi-turn.json +++ b/tests/snapshots/translation/openai-to-claude/multi-turn.json @@ -1,5 +1,5 @@ { - "max_tokens": 8192, + "max_tokens": 64000, "messages": [ { "content": [ diff --git a/tests/snapshots/translation/openai-to-gemini/with-system.json b/tests/snapshots/translation/openai-to-gemini/with-system.json index 01b0a2af5f..2c1f51acf1 100644 --- a/tests/snapshots/translation/openai-to-gemini/with-system.json +++ b/tests/snapshots/translation/openai-to-gemini/with-system.json @@ -10,7 +10,6 @@ } ], "generationConfig": { - "maxOutputTokens": 8192, "temperature": 0.7, "thinkingConfig": { "includeThoughts": true, diff --git a/tests/unit/combo-routing-engine.test.ts b/tests/unit/combo-routing-engine.test.ts index ed07c9ac1e..fc0ce964b6 100644 --- a/tests/unit/combo-routing-engine.test.ts +++ b/tests/unit/combo-routing-engine.test.ts @@ -2978,8 +2978,8 @@ test("#3587 reasoning buffer is disabled without explicit model capability data" ); assert.equal( resolveReasoningBufferedMaxTokens("openai/default-cap-reasoning", 100), - null, - "default-sized caps are treated as unknown because registry fallbacks use the same value" + 1100, + "explicit default-sized caps are treated as real capability data" ); }); diff --git a/tests/unit/combo-strategies.test.ts b/tests/unit/combo-strategies.test.ts index 25e87c0d9a..f5d5c9a26f 100644 --- a/tests/unit/combo-strategies.test.ts +++ b/tests/unit/combo-strategies.test.ts @@ -423,30 +423,32 @@ test("reset-aware strategy avoids accounts near 5h exhaustion", async (t) => { assert.equal(await selectedConnectionFor(combo), healthy5h.id); }); -test("reset-aware strategy rotates similar scores with round-robin tie breaking", async (t) => { - const first = { id: `first-${randomUUID()}`, token: `token-first-${randomUUID()}` }; - const second = { - id: `second-${randomUUID()}`, - token: `token-second-${randomUUID()}`, - }; - const reset5hAt = Math.floor((Date.now() + 2 * 3600 * 1000) / 1000); - const reset7dAt = Math.floor((Date.now() + 3 * 24 * 3600 * 1000) / 1000); +test("reset-aware strategy rotates similar scores with round-robin tie breaking", async () => { + const provider = `tie-provider-${randomUUID()}`; + const first = `first-${randomUUID()}`; + const second = `second-${randomUUID()}`; const quota = { - rate_limit: { - primary_window: { used_percent: 50, reset_at: reset5hAt }, - secondary_window: { used_percent: 50, reset_at: reset7dAt }, - }, + used: 50, + total: 100, + percentUsed: 0.5, + resetAt: "2099-01-01T00:00:00.000Z", }; - t.after( - installCodexQuotaMock({ - [first.token]: quota, - [second.token]: quota, - }) - ); - const combo = resetAwareCombo(`reset-aware-rr-${randomUUID()}`, [first, second], { - resetAwareTieBandPercent: 100, - }); + registerQuotaFetcher(provider, async () => quota); + + const combo = { + name: `reset-aware-rr-${randomUUID()}`, + strategy: "reset-aware", + config: { resetAwareTieBandPercent: 100 }, + models: [first, second].map((connectionId, index) => ({ + kind: "model", + provider, + providerId: provider, + model: "balanced-model", + connectionId, + id: `tie-${index}`, + })), + }; const selections = [ await selectedConnectionFor(combo), @@ -454,8 +456,8 @@ test("reset-aware strategy rotates similar scores with round-robin tie breaking" await selectedConnectionFor(combo), ]; - assert.equal(selections.includes(first.id), true); - assert.equal(selections.includes(second.id), true); + assert.equal(selections.includes(first), true); + assert.equal(selections.includes(second), true); }); test("reset-aware strategy uses registered quota fetchers for non-Codex providers", async () => { diff --git a/tests/unit/combo-vision-aware-routing.test.ts b/tests/unit/combo-vision-aware-routing.test.ts index 7f36874b0c..f617537743 100644 --- a/tests/unit/combo-vision-aware-routing.test.ts +++ b/tests/unit/combo-vision-aware-routing.test.ts @@ -41,10 +41,7 @@ test.after(() => { // --- Part A: capability resolution ----------------------------------------- test("Pixtral resolves supportsVision=true via model-id heuristic (no synced data)", () => { - assert.equal( - getResolvedModelCapabilities("mistral/pixtral-12b-latest").supportsVision, - true - ); + assert.equal(getResolvedModelCapabilities("mistral/pixtral-12b-latest").supportsVision, true); }); test("a text-only Mistral model is NOT a vision false-positive", () => { @@ -115,3 +112,14 @@ test("text-only request: targets are untouched by the vision filter", () => { ); assert.equal(out.length, 1); }); + +test("large output request: unknown maxOutputTokens does not filter a target", () => { + const out = filterTargetsByRequestCompatibility( + [target("openai-compatible-local/custom-large-output-model"), target("openai/gpt-4o-mini")], + { messages: [{ role: "user", content: "hello" }], max_tokens: 32000 }, + noopLog + ); + const ids = out.map((t) => t.modelStr); + + assert.deepEqual(ids, ["openai-compatible-local/custom-large-output-model"]); +}); diff --git a/tests/unit/db-usage-analytics-3500.test.ts b/tests/unit/db-usage-analytics-3500.test.ts index d61f7b1787..e9d49c4326 100644 --- a/tests/unit/db-usage-analytics-3500.test.ts +++ b/tests/unit/db-usage-analytics-3500.test.ts @@ -97,13 +97,12 @@ test.after(() => { // --------------------------------------------------------------------------- test("#3500 buildUnifiedSource — raw-only branch when sinceIso is recent", () => { - // A very recent sinceIso means no aggregated rows are needed - const recentIso = new Date(Date.now() - 3600_000).toISOString(); // 1 hour ago - const today = new Date().toISOString().split("T")[0]; + // sinceIso >= rawCutoffDate means no aggregated rows are needed. + const recentIso = "2025-06-02T12:00:00.000Z"; const result = mod.buildUnifiedSource({ sinceIso: recentIso, untilIso: null, - rawCutoffDate: today, + rawCutoffDate: "2025-06-01", apiKeyWhere: "", apiKeyParams: {}, }); diff --git a/tests/unit/model-capabilities-registry.test.ts b/tests/unit/model-capabilities-registry.test.ts index b927ca2704..53f0364c09 100644 --- a/tests/unit/model-capabilities-registry.test.ts +++ b/tests/unit/model-capabilities-registry.test.ts @@ -137,6 +137,27 @@ test("canonical model capability resolver lets exact synced metadata override gl assert.equal(bareGpt55.contextWindow, 1050000); }); +test("unknown models keep maxOutputTokens null instead of using a generic default", () => { + const unknown = modelCapabilities.getResolvedModelCapabilities( + "openai-compatible-local/custom-large-output-model" + ); + + assert.equal(unknown.contextWindow, null); + assert.equal(unknown.maxInputTokens, null); + assert.equal(unknown.maxOutputTokens, null); + assert.equal( + modelCapabilities.capMaxOutputTokens( + "openai-compatible-local/custom-large-output-model", + 32000 + ), + 32000 + ); + assert.equal( + modelCapabilities.capMaxOutputTokens("openai-compatible-local/custom-large-output-model"), + null + ); +}); + test("GPT OSS and DeepSeek Reasoner models support tool calling", () => { // GPT OSS models should not be blocked by the heuristic assert.equal(modelCapabilities.supportsToolCalling("fake-provider/gpt-oss-120b"), true); diff --git a/tests/unit/translator-claude-to-gemini.test.ts b/tests/unit/translator-claude-to-gemini.test.ts index 70d63f595a..39c48d42b4 100644 --- a/tests/unit/translator-claude-to-gemini.test.ts +++ b/tests/unit/translator-claude-to-gemini.test.ts @@ -121,6 +121,19 @@ test("Claude -> Gemini clamps maxOutputTokens to the model cap", () => { assert.equal(result.generationConfig.maxOutputTokens, 65536); }); +test("Claude -> Gemini preserves requested maxOutputTokens when the model cap is unknown", () => { + const result = claudeToGeminiRequest( + "gemini-2.5-pro", + { + messages: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], + max_tokens: 32000, + }, + false + ); + + assert.equal(result.generationConfig.maxOutputTokens, 32000); +}); + test("Claude -> Gemini converts text and base64 images to Gemini parts", () => { const result = claudeToGeminiRequest( "gemini-2.5-flash", diff --git a/tests/unit/translator-openai-to-claude.test.ts b/tests/unit/translator-openai-to-claude.test.ts index 6b18b85681..89e49121aa 100644 --- a/tests/unit/translator-openai-to-claude.test.ts +++ b/tests/unit/translator-openai-to-claude.test.ts @@ -256,7 +256,9 @@ test("OpenAI -> Claude maps tool_choice and injects response_format instructions }); test("OpenAI -> Claude turns reasoning settings into thinking budgets and expands max tokens", () => { - // `claude-4-sonnet` is a fixture that doesn't match any spec → default cap = 8192. + // `claude-4-sonnet` is a fixture that doesn't match any spec. Unknown caps + // should not get an implicit default; the translator only preserves the + // response room + thinking budget relationship. // fitThinkingToMaxTokens floors response room at MIN_RESPONSE_ROOM (1024) // and targets max_tokens = responseRoom + budget capped at modelCap. const effortResult = openaiToClaudeRequest( @@ -270,7 +272,7 @@ test("OpenAI -> Claude turns reasoning settings into thinking budgets and expand ); assert.deepEqual(effortResult.thinking, { type: "enabled", budget_tokens: 1024 }); - // responseRoom=max(10,1024)=1024; target=min(1024+1024, 8192)=2048 + // responseRoom=max(10,1024)=1024; target=1024+1024=2048 assert.equal(effortResult.max_tokens, 2048); const explicitThinkingResult = openaiToClaudeRequest( @@ -288,10 +290,25 @@ test("OpenAI -> Claude turns reasoning settings into thinking budgets and expand budget_tokens: 2000, max_tokens: 3000, }); - // responseRoom=max(1000,1024)=1024; target=min(1024+2000, 8192)=3024 + // responseRoom=max(1000,1024)=1024; target=1024+2000=3024 assert.equal(explicitThinkingResult.max_tokens, 3024); }); +test("OpenAI -> Claude does not cap unknown models to a fallback maxOutputTokens", () => { + const result = openaiToClaudeRequest( + "claude-4-sonnet", + { + messages: [{ role: "user", content: "Reason about something hard" }], + max_tokens: 32000, + reasoning_effort: "high", + }, + false + ); + + assert.equal(result.max_tokens, 163072); + assert.deepEqual(result.thinking, { type: "enabled", budget_tokens: 131072 }); +}); + test("OpenAI -> Claude preserves xhigh only for Claude models that expose it", () => { const { xhighModel, standardModel } = getClaudeEffortFixtures(); const preserved = openaiToClaudeRequest( diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index b775371229..8f75dba4c7 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -18,6 +18,8 @@ const { clearGeminiThoughtSignatures } = await import("../../open-sse/services/geminiThoughtSignatureStore.ts"); type UnknownRecord = Record; +type GeminiRequestWithConfig = { generationConfig: UnknownRecord }; +type GeminiRequestWithSystem = { systemInstruction: { role?: unknown; parts?: unknown } }; test.beforeEach(() => { clearGeminiThoughtSignatures(); @@ -76,6 +78,28 @@ test("OpenAI -> Gemini helper converts text, images and files into Gemini parts" assert.deepEqual(convertOpenAIContentToParts("raw text"), [{ text: "raw text" }]); }); +test("OpenAI -> Gemini does not inject default maxOutputTokens for unknown caps", () => { + const withoutRequestLimit = openaiToGeminiRequest( + "gemini-2.5-pro", + { messages: [{ role: "user", content: "Hello" }] }, + false + ); + assert.equal( + (withoutRequestLimit as GeminiRequestWithConfig).generationConfig.maxOutputTokens, + undefined + ); + + const withRequestLimit = openaiToGeminiRequest( + "gemini-2.5-pro", + { messages: [{ role: "user", content: "Hello" }], max_tokens: 32000 }, + false + ); + assert.equal( + (withRequestLimit as GeminiRequestWithConfig).generationConfig.maxOutputTokens, + 32000 + ); +}); + test("OpenAI -> Gemini helper cleans complex JSON Schema structures for Gemini compatibility", () => { const cleaned = cleanJSONSchemaForAntigravity({ type: "object", @@ -237,11 +261,9 @@ test("OpenAI -> Gemini request maps messages, merged system instructions, tools false ); - assert.equal((result as any).systemInstruction.role, "system"); - assert.deepEqual((result as any).systemInstruction.parts, [ - { text: "Rule A" }, - { text: "Rule B" }, - ]); + const systemInstruction = (result as GeminiRequestWithSystem).systemInstruction; + assert.equal(systemInstruction.role, "system"); + assert.deepEqual(systemInstruction.parts, [{ text: "Rule A" }, { text: "Rule B" }]); assert.equal(result.contents[0].role, "user"); assert.deepEqual(result.contents[0].parts, [ { text: "What is the weather?" }, @@ -270,12 +292,13 @@ test("OpenAI -> Gemini request maps messages, merged system instructions, tools response: { result: { temp: 20 } }, }); - assert.equal((result as any).generationConfig.maxOutputTokens, 2222); - assert.equal((result as any).generationConfig.temperature, 0.3); - assert.equal((result as any).generationConfig.topP, 0.9); - assert.deepEqual((result as any).generationConfig.stopSequences, ["DONE"]); - assert.equal((result as any).generationConfig.responseMimeType, "application/json"); - const responseSchema = (result as any).generationConfig.responseSchema as { + const generationConfig = (result as GeminiRequestWithConfig).generationConfig; + assert.equal(generationConfig.maxOutputTokens, 2222); + assert.equal(generationConfig.temperature, 0.3); + assert.equal(generationConfig.topP, 0.9); + assert.deepEqual(generationConfig.stopSequences, ["DONE"]); + assert.equal(generationConfig.responseMimeType, "application/json"); + const responseSchema = generationConfig.responseSchema as { properties: { answer: { type: string; enum?: string[] } }; }; assert.equal(responseSchema.properties.answer.type, "string"); @@ -690,17 +713,19 @@ test("OpenAI -> Antigravity Gemini omits signature-less historical tool calls an "signature-less historical call MUST be emitted as native functionCall (bypass applied)" ); assert.equal( - modelTurn?.parts.some((part) => part.thoughtSignature === "skip_thought_signature_validator") ?? false, + modelTurn?.parts.some((part) => part.thoughtSignature === "skip_thought_signature_validator") ?? + false, true, "the bypass sentinel must be injected as thoughtSignature" ); const toolTurn = result.request.contents.find( - (content) => - content.role === "user" && - content.parts.some((part) => part.functionResponse) + (content) => content.role === "user" && content.parts.some((part) => part.functionResponse) + ); + assert.ok( + toolTurn, + "expected signature-less tool response to be preserved as native functionResponse (bypass applied)" ); - assert.ok(toolTurn, "expected signature-less tool response to be preserved as native functionResponse (bypass applied)"); assert.equal( toolTurn.parts.some((part) => part.functionResponse), true,