diff --git a/src/api/providers/fetchers/__tests__/openrouter.spec.ts b/src/api/providers/fetchers/__tests__/openrouter.spec.ts index 2f19487564..7ed1821d7b 100644 --- a/src/api/providers/fetchers/__tests__/openrouter.spec.ts +++ b/src/api/providers/fetchers/__tests__/openrouter.spec.ts @@ -382,5 +382,80 @@ describe("OpenRouter API", () => { expect(textResult.maxTokens).toBe(64000) expect(imageResult.maxTokens).toBe(64000) }) + + it("handles context overflow issue - caps max_completion_tokens when it equals context window", () => { + const mockModel = { + name: "Kimi K2", + description: "Test model with context overflow issue", + context_length: 131072, + max_completion_tokens: 131072, // This equals context window, causing the issue + pricing: { + prompt: "0.000003", + completion: "0.000015", + }, + } + + const result = parseOpenRouterModel({ + id: "moonshotai/kimi-k2", + model: mockModel, + inputModality: ["text"], + outputModality: ["text"], + maxTokens: 131072, // This would cause context overflow + }) + + // Should cap to 20% of context window instead of using full context window + expect(result.maxTokens).toBe(Math.ceil(131072 * 0.2)) // 26215 + expect(result.contextWindow).toBe(131072) + }) + + it("uses provided max_completion_tokens when it's reasonable (less than context window)", () => { + const mockModel = { + name: "Reasonable Model", + description: "Test model with reasonable max_completion_tokens", + context_length: 131072, + max_completion_tokens: 65536, // Half of context window - reasonable + pricing: { + prompt: "0.000003", + completion: "0.000015", + }, + } + + const result = parseOpenRouterModel({ + id: "test/reasonable-model", + model: mockModel, + inputModality: ["text"], + outputModality: ["text"], + maxTokens: 65536, + }) + + // Should use the provided maxTokens since it's reasonable + expect(result.maxTokens).toBe(65536) + expect(result.contextWindow).toBe(131072) + }) + + it("falls back to 20% of context window when max_completion_tokens is not provided", () => { + const mockModel = { + name: "No Max Tokens Model", + description: "Test model without max_completion_tokens", + context_length: 100000, + max_completion_tokens: null, + pricing: { + prompt: "0.000003", + completion: "0.000015", + }, + } + + const result = parseOpenRouterModel({ + id: "test/no-max-tokens", + model: mockModel, + inputModality: ["text"], + outputModality: ["text"], + maxTokens: null, + }) + + // Should fall back to 20% of context window + expect(result.maxTokens).toBe(Math.ceil(100000 * 0.2)) // 20000 + expect(result.contextWindow).toBe(100000) + }) }) }) diff --git a/src/api/providers/fetchers/openrouter.ts b/src/api/providers/fetchers/openrouter.ts index 7e3dd7609f..6432ebceed 100644 --- a/src/api/providers/fetchers/openrouter.ts +++ b/src/api/providers/fetchers/openrouter.ts @@ -206,8 +206,20 @@ export const parseOpenRouterModel = ({ const supportsPromptCache = typeof cacheReadsPrice !== "undefined" // some models support caching but don't charge a cacheWritesPrice, e.g. GPT-5 + // Calculate safe max output tokens + // If maxTokens from OpenRouter equals or exceeds the context window, use 20% of context window instead + // This prevents the "max_tokens equals context window" issue that causes API failures + let safeMaxTokens: number + if (maxTokens && maxTokens < model.context_length) { + // Use the provided max_completion_tokens if it's reasonable (less than context window) + safeMaxTokens = maxTokens + } else { + // Fall back to 20% of context window for safety + safeMaxTokens = Math.ceil(model.context_length * 0.2) + } + const modelInfo: ModelInfo = { - maxTokens: maxTokens || Math.ceil(model.context_length * 0.2), + maxTokens: safeMaxTokens, contextWindow: model.context_length, supportsImages: inputModality?.includes("image") ?? false, supportsPromptCache, diff --git a/src/shared/__tests__/api.spec.ts b/src/shared/__tests__/api.spec.ts index aaeb1bf444..3ffadb577d 100644 --- a/src/shared/__tests__/api.spec.ts +++ b/src/shared/__tests__/api.spec.ts @@ -283,6 +283,90 @@ describe("getModelMaxOutputTokens", () => { }) }) + test("should not treat OpenRouter GPT-5 models as native GPT-5 models", () => { + const model: ModelInfo = { + contextWindow: 200_000, + supportsPromptCache: false, + maxTokens: 128_000, // 64% of context window, should be capped for OpenRouter models + } + + const settings: ProviderSettings = { + apiProvider: "openrouter", + } + + // Test OpenRouter GPT-5 model IDs that should be capped (non-OpenAI providers) + const openRouterGpt5ModelIds = [ + "openrouter/gpt-5", + "some-provider/gpt-5-preview", + "anthropic/gpt-5", // hypothetical + "moonshotai/gpt-5-like", // hypothetical + ] + + openRouterGpt5ModelIds.forEach((modelId) => { + const result = getModelMaxOutputTokens({ + modelId, + model, + settings, + format: "openrouter", + }) + // Should be capped to 20% of context window: 200_000 * 0.2 = 40_000 + // NOT the full 128_000 like native GPT-5 models + expect(result).toBe(40_000) + }) + }) + + test("should still bypass 20% cap for native GPT-5 models (without slash)", () => { + const model: ModelInfo = { + contextWindow: 200_000, + supportsPromptCache: false, + maxTokens: 128_000, // 64% of context window + } + + const settings: ProviderSettings = { + apiProvider: "openai", + } + + // Test native GPT-5 model IDs (without slash) + const nativeGpt5ModelIds = ["gpt-5", "gpt-5-turbo", "GPT-5", "gpt-5-32k"] + + nativeGpt5ModelIds.forEach((modelId) => { + const result = getModelMaxOutputTokens({ + modelId, + model, + settings, + format: "openai", + }) + // Should use full 128k tokens, not capped to 20% (40k) + expect(result).toBe(128_000) + }) + }) + + test("should still bypass 20% cap for OpenAI GPT-5 models through OpenRouter", () => { + const model: ModelInfo = { + contextWindow: 200_000, + supportsPromptCache: false, + maxTokens: 128_000, // 64% of context window + } + + const settings: ProviderSettings = { + apiProvider: "openrouter", + } + + // Test OpenAI GPT-5 model IDs through OpenRouter (should still bypass cap) + const openaiGpt5ModelIds = ["openai/gpt-5", "openai/gpt-5-turbo", "openai/gpt-5-preview"] + + openaiGpt5ModelIds.forEach((modelId) => { + const result = getModelMaxOutputTokens({ + modelId, + model, + settings, + format: "openrouter", + }) + // Should use full 128k tokens, not capped to 20% (40k) + expect(result).toBe(128_000) + }) + }) + test("should return modelMaxTokens from settings when reasoning budget is required", () => { const model: ModelInfo = { contextWindow: 200_000, diff --git a/src/shared/api.ts b/src/shared/api.ts index eb3ae124a8..adfeeee59c 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -122,14 +122,18 @@ export const getModelMaxOutputTokens = ({ // Exception: GPT-5 models should use their exact configured max output tokens if (model.maxTokens) { // Check if this is a GPT-5 model (case-insensitive) - const isGpt5Model = modelId.toLowerCase().includes("gpt-5") + // Make sure we don't incorrectly identify OpenRouter models as GPT-5 + // OpenRouter models typically have format "provider/model" but native OpenAI models can be "openai/gpt-5" + const isGpt5Model = + modelId.toLowerCase().includes("gpt-5") && (format !== "openrouter" || modelId.startsWith("openai/")) // GPT-5 models bypass the 20% cap and use their full configured max tokens if (isGpt5Model) { return model.maxTokens } - // All other models are clamped to 20% of context window + // All other models (including OpenRouter models) are clamped to 20% of context window + // This prevents context overflow issues where max_tokens equals the full context window return Math.min(model.maxTokens, Math.ceil(model.contextWindow * 0.2)) }