allow max-tokenless models on openrouter

This commit is contained in:
Neal Shah
2026-08-10 18:32:28 -04:00
parent 1dec5e90c4
commit 1bfe982fc4
4 changed files with 105 additions and 8 deletions
@@ -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([
{
@@ -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
@@ -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();
@@ -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<string[]>} 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',