From 1bfe982fc47dec9a9d45727ada02d83e374518fa Mon Sep 17 00:00:00 2001 From: Neal Shah Date: Mon, 10 Aug 2026 18:32:28 -0400 Subject: [PATCH] allow max-tokenless models on openrouter --- .../ai-chat/ChatCompletionDriver.test.ts | 58 +++++++++++++++++++ .../drivers/ai-chat/ChatCompletionDriver.ts | 10 +++- .../openrouter/OpenRouterProvider.test.ts | 28 +++++++++ .../openrouter/OpenRouterProvider.ts | 17 +++--- 4 files changed, 105 insertions(+), 8 deletions(-) diff --git a/src/backend/drivers/ai-chat/ChatCompletionDriver.test.ts b/src/backend/drivers/ai-chat/ChatCompletionDriver.test.ts index 251774acc..b4d1a65b0 100644 --- a/src/backend/drivers/ai-chat/ChatCompletionDriver.test.ts +++ b/src/backend/drivers/ai-chat/ChatCompletionDriver.test.ts @@ -587,6 +587,64 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => { expect(completeSpy).not.toHaveBeenCalled(); }); + // A provider that can't report a model's output ceiling used to make the + // cap arithmetic go negative — `null - approxTokens` is negative, not NaN + // — so a funded account was told it had insufficient funds. + for (const [label, ceiling] of [ + ['null', null], + ['zero', 0], + ['undefined', undefined], + ] as const) { + it(`serves models whose output ceiling is ${label}`, async () => { + vi.spyOn( + FakeChatProvider.prototype, + 'models', + ).mockResolvedValueOnce([ + { + id: 'nocap', + aliases: [], + costs_currency: 'usd-cents', + costs: { input_tokens: 1000, output_tokens: 2000 }, + max_tokens: ceiling, + }, + ] as never); + const d = await makeDriver(); + + vi.spyOn( + server.services.metering, + 'hasEnoughCredits', + ).mockResolvedValue(true); + vi.spyOn( + server.services.metering, + 'getRemainingUsage', + ).mockResolvedValue(100_000); + + const completeSpy = vi + .spyOn(FakeChatProvider.prototype, 'complete') + .mockResolvedValueOnce({ + message: { + role: 'assistant', + content: [{ type: 'text', text: 'ok' }], + }, + usage: { input_tokens: 1, output_tokens: 1 }, + finish_reason: 'stop', + } as never); + + await withTestActor(() => + d.complete({ + model: 'nocap', + messages: [{ role: 'user', content: 'hi' }], + }), + ); + + // Credits still bound the request; only the unknown model + // ceiling is ignored. + const passed = completeSpy.mock.calls[0]![0] as ICompleteArguments; + expect(passed.max_tokens!).toBeGreaterThan(0); + expect(passed.max_tokens!).toBeLessThanOrEqual(50); + }); + } + it('rejects subscriber-only models for the default free subscription', async () => { vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([ { diff --git a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts index 9c2c49c57..6df950988 100644 --- a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts +++ b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts @@ -407,11 +407,19 @@ export class ChatCompletionDriver extends PuterDriver { remainingCredits - approximateInputCost; const maxAllowedOutputTokens = maxAllowedOutputUcents / outputTokenCost; + // A provider may not know a model's output ceiling. Drop the + // term rather than let a missing value drive the cap: `null` + // coerces to 0, so the subtraction goes negative instead of + // NaN and the user is told they're out of credits. + const modelOutputCeiling = + Number.isFinite(model.max_tokens) && model.max_tokens > 0 + ? model.max_tokens - approximateTokenCount + : Number.POSITIVE_INFINITY; const cap = Math.floor( Math.min( args.max_tokens ?? Number.POSITIVE_INFINITY, maxAllowedOutputTokens, - model.max_tokens - approximateTokenCount, + modelOutputCeiling, ), ); // `cap` is the credit-bounded ceiling on output tokens. When diff --git a/src/backend/drivers/ai-chat/providers/openrouter/OpenRouterProvider.test.ts b/src/backend/drivers/ai-chat/providers/openrouter/OpenRouterProvider.test.ts index 503d94a88..fb5b67301 100644 --- a/src/backend/drivers/ai-chat/providers/openrouter/OpenRouterProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/openrouter/OpenRouterProvider.test.ts @@ -105,6 +105,15 @@ const SAMPLE_API_MODELS = [ pricing: { prompt: 0.000002, completion: 0.00001 }, top_provider: { max_completion_tokens: 8192 }, }, + { + // OpenRouter reports no separate output cap for some models. + id: 'meta/muse-spark-1.2', + name: 'Muse Spark 1.2', + created: 1786032000, + context_length: 1048576, + pricing: { prompt: 0.00000125, completion: 0.00000425 }, + top_provider: { max_completion_tokens: null }, + }, { // 'openrouter/auto' is filtered out — disallowed. id: 'openrouter/auto', @@ -228,6 +237,25 @@ describe('OpenRouterProvider model catalog', () => { expect(axiosRequestMock).toHaveBeenCalledTimes(1); }); + it('falls back to the context window when no output cap is reported', async () => { + const { provider } = makeProvider(); + const models = await provider.models(); + + // A null max_completion_tokens previously landed on the model as-is, + // which made the driver's cap arithmetic go negative and reject the + // request as insufficient funds. + const noCap = models.find( + (m) => m.id === 'openrouter:meta/muse-spark-1.2', + )!; + expect(noCap.max_tokens).toBe(1048576); + + // A model that reports its own cap keeps it. + const capped = models.find( + (m) => m.id === 'openrouter:openai/gpt-5-nano', + )!; + expect(capped.max_tokens).toBe(16000); + }); + it('maps OpenRouter created timestamps to release_date metadata', async () => { const { provider } = makeProvider(); const models = await provider.models(); diff --git a/src/backend/drivers/ai-chat/providers/openrouter/OpenRouterProvider.ts b/src/backend/drivers/ai-chat/providers/openrouter/OpenRouterProvider.ts index bf633c425..74a254418 100644 --- a/src/backend/drivers/ai-chat/providers/openrouter/OpenRouterProvider.ts +++ b/src/backend/drivers/ai-chat/providers/openrouter/OpenRouterProvider.ts @@ -72,9 +72,11 @@ export class OpenRouterProvider implements IChatProvider { } /** * Returns a list of available model names including their aliases + * + * Retrieves all available model IDs and their aliases, flattening them into + * a single array of strings that can be used for model selection + * * @returns {Promise} Array of model identifiers and their aliases - * @description Retrieves all available model IDs and their aliases, - * flattening them into a single array of strings that can be used for model selection */ async list() { const models = await this.models(); @@ -85,10 +87,7 @@ export class OpenRouterProvider implements IChatProvider { return model_names; } - /** - * AI Chat completion method. - * See AIChatService for more details. - */ + /** AI Chat completion method. See AIChatService for more details. */ async complete({ messages, stream, @@ -279,7 +278,11 @@ export class OpenRouterProvider implements IChatProvider { model.id.split('/').slice(1).join('/'), ], context: model.context_length, - max_tokens: model.top_provider.max_completion_tokens, + // OpenRouter leaves max_completion_tokens null when a model + // declares no output cap separate from its context window. + max_tokens: + model.top_provider.max_completion_tokens ?? + model.context_length, costs_currency: 'usd-cents', input_cost_key: 'prompt', output_cost_key: 'completion',