mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 00:02:20 +03:00
fix(models): treat unknown output caps as unset (#4584)
Integrated into release/v3.8.34
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 ─────────────────────────────────────────
|
||||
|
||||
@@ -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<string, unknown> = { ...thinking };
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, ModelSpec> = {
|
||||
},
|
||||
|
||||
// Defaults
|
||||
__default__: {
|
||||
maxOutputTokens: 8192,
|
||||
},
|
||||
__default__: {},
|
||||
};
|
||||
|
||||
export function getModelSpec(modelId: string): ModelSpec | undefined {
|
||||
@@ -515,10 +513,12 @@ export function normalizeThinkingForModel<T extends Record<string, unknown>>(
|
||||
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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"max_tokens": 8192,
|
||||
"max_tokens": 64000,
|
||||
"messages": [
|
||||
{
|
||||
"content": [
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
}
|
||||
],
|
||||
"generationConfig": {
|
||||
"maxOutputTokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"thinkingConfig": {
|
||||
"includeThoughts": true,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -18,6 +18,8 @@ const { clearGeminiThoughtSignatures } =
|
||||
await import("../../open-sse/services/geminiThoughtSignatureStore.ts");
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user