Handle model access denials and filter batch chat variants

This commit is contained in:
404oops authored and GitHub committed 2026-10-16 21:22:00 -07:00
1 parent 471b900462
commit f2445fdd2d
7 files changed
+851 -15

No files matched your search

@@ -0,0 +1,199 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { createServer, type Server } from 'node:http';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
import { clearUnhealthyRoutes } from './utils/providerHealth.js';
import { makeActor } from '../../core/actor.js';
const policyMessage =
'Policy Violation: this user has been blocked for a previous policy violation. Learn more: https://vendor.test/private';
const routes = [
'/drivers/call',
'/puterai/openai/v1/chat/completions',
'/puterai/anthropic/v1/messages',
];
const cases = [
{
name: 'policy block',
status: 403,
body: { error: { code: 403, message: policyMessage } },
expected: [400, 404, 404],
},
{
name: 'in-band policy block',
status: 200,
body: { error: { code: 403, message: policyMessage } },
expected: [400, 404, 404],
},
{
name: 'bad credentials',
status: 401,
body: { error: { code: 401, message: 'Invalid API key' } },
expected: [500, 500, 500],
},
{
name: 'generic forbidden',
status: 403,
body: { error: { code: 403, message: 'Forbidden' } },
expected: [500, 500, 500],
},
{
name: 'credit exhaustion',
status: 403,
body: {
error: {
code: 'insufficient_user_quota',
message: 'user quota is not enough',
},
},
expected: [503, 503, 503],
},
{
name: 'outage',
status: 503,
body: { error: { code: 503, message: 'service unavailable' } },
expected: [400, 400, 502],
},
{
name: 'empty choices',
status: 200,
body: { choices: [] },
expected: [400, 400, 400],
},
];
describe('model availability through real HTTP', () => {
let upstream: Server;
let env: PuterTestEnv;
let response = cases[0];
beforeAll(async () => {
upstream = createServer((req, res) => {
res.setHeader('content-type', 'application/json');
if (req.url === '/v1/models') {
const model = {
id: 'vendor/availability-test',
name: 'Availability test',
context_length: 4096,
top_provider: { max_completion_tokens: 256 },
pricing: { prompt: 0.000001, completion: 0.000001 },
};
res.end(
JSON.stringify({
data: [
model,
{
...model,
id: `${model.id}:batch`,
name: 'Batch only',
},
],
}),
);
return;
}
req.resume();
req.on('end', () => {
res.statusCode = response.status;
res.end(JSON.stringify(response.body));
});
});
await new Promise<void>((resolve) =>
upstream.listen(0, '127.0.0.1', resolve),
);
const port = (upstream.address() as { port: number }).port;
env = await setupPuterTestEnv({
providers: {
openrouter: {
apiKey: 'synthetic-key',
apiBaseUrl: `http://127.0.0.1:${port}/v1`,
},
},
meteringEnforcement: { subscriptions: false },
} as never);
}, 120000);
afterAll(async () => {
await env?.shutdown();
upstream?.closeAllConnections();
if (upstream)
await new Promise<void>((resolve) =>
upstream.close(() => resolve()),
);
});
beforeEach(() => clearUnhealthyRoutes());
it('excludes batch IDs and aliases', async () => {
const models = await env.server.drivers.aiChat.models();
expect(
models.some((m) => m.id === 'openrouter:vendor/availability-test'),
).toBe(true);
expect(JSON.stringify(models)).not.toContain(':batch');
});
for (const [index, route] of routes.entries()) {
it.each(cases)(
`$name on ${route}`,
async (current) => {
response = current;
const testUser = index === 2 ? env.users.other : env.users.user;
const args = {
model: 'openrouter:vendor/availability-test',
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 16,
stream: false,
};
const res = await fetch(new URL(route, env.apiOrigin), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${testUser.apiToken}`,
Origin: env.apiOrigin,
},
body: JSON.stringify(
route === '/drivers/call'
? {
interface: 'puter-chat-completion',
service: 'ai-chat',
method: 'complete',
args,
}
: args,
),
});
const body = await res.text();
expect(res.status, body).toBe(current.expected[index]);
if (current.name.includes('policy block')) {
expect(body).toMatch(/model.*(not available|unavailable)/i);
expect(body).not.toMatch(
/vendor\.test|policy violation|private/i,
);
}
const user = await env.server.stores.user.getByUsername(
testUser.username,
);
expect(
await env.server.services.metering.getOutstandingHolds(
makeActor({ user: user! }),
),
).toBe(0);
},
30000,
);
}
});
@@ -539,6 +539,80 @@ describe('ChatCompletionDriver exhausted-chain classification', () => {
});
});
it('maps an upstream 403 model_not_found to the model-unavailable 400, not an auth failure, and redacts its URL', async () => {
const err = await errorFor({
status: 403,
message:
'The model `fake` is not available to your account. See https://vendor.test/models (request id: req-secret)',
error: {
code: 'model_not_found',
message: 'The model `fake` is not available to your account.',
},
});
expect(err).toMatchObject({
statusCode: 400,
legacyCode: 'upstream_bad_request',
fields: expect.objectContaining({ upstreamStatus: 404 }),
});
expect(err.noAlarm).toBeFalsy();
expect(err.message).toBe('Model not available: fake');
expect(JSON.stringify(attemptsFrom(err))).not.toMatch(
/http|request id/i,
);
});
it.each([
['no code', {}],
['another code', { error: { code: 'access_denied' } }],
])(
'keeps a 403 with %s as 500 upstream_auth_failed',
async (_label, extra) => {
const err = await errorFor({
status: 403,
message: 'Forbidden',
...extra,
});
expect(err).toMatchObject({
statusCode: 500,
legacyCode: 'upstream_auth_failed',
});
},
);
it('keeps a 404 whose message reads like a credential failure an auth failure', async () => {
const err = await errorFor(
Object.assign(new Error('Unauthorized'), { status: 404 }),
);
expect(err).toMatchObject({
statusCode: 500,
legacyCode: 'upstream_auth_failed',
});
});
it('keeps a 401 model_not_found an auth failure', async () => {
const err = await errorFor({
status: 401,
message: 'invalid api key',
error: { code: 'model_not_found' },
});
expect(err).toMatchObject({
statusCode: 500,
legacyCode: 'upstream_auth_failed',
});
});
it('classifies a 403 insufficient_user_quota as credit exhaustion', async () => {
const err = await errorFor({
status: 403,
message: 'user quota is not enough',
error: { code: 'insufficient_user_quota' },
});
expect(err).toMatchObject({
statusCode: 503,
legacyCode: 'upstream_credits_exhausted',
});
});
it('sniffs a status out of the message when the provider throws a bare Error', async () => {
const err = await errorFor(new Error('provider blew up with 503'));
expect(err).toMatchObject({
@@ -40,6 +40,12 @@ import {
type MockInstance,
} from 'vitest';
import type { Request } from 'express';
import {
renderAnthropicError,
renderOpenAIError,
} from '../../controllers/puterai/wireErrors.js';
import { Context } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
@@ -143,6 +149,7 @@ const OPENROUTER_CATALOG = [
// also the alias the Meta provider publishes — the two have to land in
// one bucket rather than becoming separate models.
'meta/muse-spark-1.2',
'openai/o1-pro',
].map((id) => ({
id,
name: `${id} (via OpenRouter)`,
@@ -475,6 +482,366 @@ describe('ChatCompletionDriver muse spark routing', () => {
});
});
describe('ChatCompletionDriver model-unavailable routes', () => {
// Shaped like the OpenAI SDK's APIError for an OpenAI-convention body.
const modelNotFound = () =>
Object.assign(
new Error('403 The model is not available to your account.'),
{
status: 403,
error: {
code: 'model_not_found',
message: 'The model is not available to your account.',
},
},
);
const statusError = (status: number, message: string) =>
Object.assign(new Error(message), { status });
const reply = {
choices: [
{
message: { role: 'assistant', content: 'hi' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 1, completion_tokens: 1 },
};
const complete = (model: string, provider?: string) =>
withTestActor(() =>
driver.complete({
model,
...(provider ? { provider } : {}),
messages: [{ role: 'user', content: 'hi' }],
}),
).catch((e: unknown) => e as HttpError);
const statusesOf = (err: HttpError) =>
(
err as unknown as { fields: { attempts: { status?: number }[] } }
).fields.attempts.map((a) => a.status);
it('falls back past a gated route and marks only that route', async () => {
createMock
.mockRejectedValueOnce(modelNotFound())
.mockResolvedValueOnce(reply);
await expect(complete('deepseek-v4-pro')).resolves.toMatchObject({
message: { content: 'hi' },
});
expect(isRouteUnhealthy('deepseek', 'deepseek-v4-pro')).toBe(true);
expect(
isRouteUnhealthy('infron', 'infron:deepseek/deepseek-v4-pro'),
).toBe(false);
});
it('keeps serving a model from its vendor after the gateway route is gated', async () => {
createMock
.mockRejectedValueOnce(modelNotFound())
.mockResolvedValueOnce(reply)
.mockResolvedValueOnce(reply);
await expect(
complete('muse-spark-1.2', 'openrouter'),
).resolves.toMatchObject({ message: { content: 'hi' } });
expect(createMock.mock.calls.at(-1)![0].model).toBe('muse-spark-1.2');
expect(
isRouteUnhealthy('openrouter', 'openrouter:meta/muse-spark-1.2'),
).toBe(true);
expect(isRouteUnhealthy('meta', 'muse-spark-1.2')).toBe(false);
await expect(complete('meta/muse-spark-1.2')).resolves.toMatchObject({
message: { content: 'hi' },
});
expect(createMock.mock.calls.at(-1)![0].model).toBe('muse-spark-1.2');
});
it('keeps a gated route marked for as long as bad credentials', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
const start = Date.now();
try {
createMock
.mockRejectedValueOnce(modelNotFound())
.mockResolvedValueOnce(reply);
await complete('deepseek-v4-pro');
vi.setSystemTime(start + 61 * 1000);
expect(isRouteUnhealthy('deepseek', 'deepseek-v4-pro')).toBe(true);
vi.setSystemTime(start + (10 * 60 + 1) * 1000);
expect(isRouteUnhealthy('deepseek', 'deepseek-v4-pro')).toBe(false);
} finally {
vi.useRealTimers();
}
});
it('tells the caller the model is unavailable when every route is gated or missing', async () => {
createMock
.mockRejectedValueOnce(modelNotFound())
.mockRejectedValue(statusError(404, 'model not found'));
const err = await complete('deepseek-v4-pro');
expect(err).toMatchObject({
statusCode: 400,
legacyCode: 'upstream_bad_request',
fields: expect.objectContaining({ upstreamStatus: 404 }),
});
expect(statusesOf(err)).toEqual([403, 404, 404]);
});
it('still pages when a gated route sits beside a bad credential', async () => {
createMock
.mockRejectedValueOnce(modelNotFound())
.mockRejectedValue(statusError(401, 'invalid api key'));
const err = await complete('deepseek-v4-pro');
expect(err).toMatchObject({
statusCode: 500,
legacyCode: 'upstream_auth_failed',
});
expect(statusesOf(err)).toEqual([403, 401, 401]);
});
it('classifies a gated route beside an outage as a mixed upstream failure', async () => {
createMock
.mockRejectedValueOnce(modelNotFound())
.mockRejectedValue(statusError(503, 'service unavailable'));
const err = await complete('deepseek-v4-pro');
expect(err).toMatchObject({
statusCode: 400,
legacyCode: 'upstream_failed',
});
expect(statusesOf(err)).toEqual([403, 503, 503]);
});
});
describe('ChatCompletionDriver OpenRouter account policy block', () => {
const POLICY_MESSAGE =
'Policy Violation: this user has been blocked for a previous policy violation. Learn more: https://openrouter.ai/docs/policy (request id: req-secret)';
// Shaped like the OpenAI SDK's APIError for OpenRouter's error body.
const policyBlock = () =>
Object.assign(new Error(`403 ${POLICY_MESSAGE}`), {
status: 403,
code: 403,
error: { code: 403, message: POLICY_MESSAGE },
});
const reply = {
choices: [
{
message: { role: 'assistant', content: 'hi' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 1, completion_tokens: 1 },
};
const complete = (model: string, provider?: string) =>
withTestActor(() =>
driver.complete({
model,
...(provider ? { provider } : {}),
messages: [{ role: 'user', content: 'hi' }],
}),
).catch((e: unknown) => e as HttpError);
const attemptsOf = (err: HttpError) =>
(
err as unknown as {
fields: {
attempts: {
provider: string;
status?: number;
code?: unknown;
}[];
};
}
).fields.attempts;
it.each(['openai/o1-pro', 'openrouter:openai/o1-pro', 'o1-pro'])(
'tells a caller asking for %s that the model is unavailable',
async (model) => {
createMock.mockRejectedValueOnce(policyBlock());
const err = await complete(model);
expect(err).toMatchObject({
statusCode: 400,
legacyCode: 'upstream_bad_request',
message: 'Model not available: openrouter:openai/o1-pro',
fields: expect.objectContaining({ upstreamStatus: 404 }),
});
expect(JSON.stringify(err.fields)).not.toMatch(
/policy|http|request id/i,
);
},
);
it('treats the in-band policy block of a 200 with no choices the same way', async () => {
createMock.mockResolvedValueOnce({
error: { code: 403, message: POLICY_MESSAGE },
});
const err = await complete('openai/o1-pro');
expect(err).toMatchObject({
statusCode: 400,
legacyCode: 'upstream_bad_request',
fields: expect.objectContaining({ upstreamStatus: 404 }),
});
expect(attemptsOf(err)).toEqual([
expect.objectContaining({
provider: 'openrouter',
status: 403,
code: 'model_not_found',
}),
]);
});
it('keeps a no-choice completion without the policy block a request-level 400', async () => {
createMock.mockResolvedValueOnce({});
const err = await complete('openai/o1-pro');
expect(err).toMatchObject({
statusCode: 400,
legacyCode: 'upstream_bad_request',
message: 'no completion was returned',
});
expect(err.fields?.upstreamStatus).toBe(400);
expect(isRouteUnhealthy('openrouter', 'openrouter:openai/o1-pro')).toBe(
false,
);
});
it('falls back from a policy-blocked gateway route to the vendor, under the Anthropic route too', async () => {
for (const strict of [false, true]) {
clearUnhealthyRoutes();
createMock
.mockRejectedValueOnce(policyBlock())
.mockResolvedValueOnce(reply);
const res = await withTestActor(() => {
if (strict) Context.set('strictUpstreamErrors', true);
return driver.complete({
model: 'muse-spark-1.2',
provider: 'openrouter',
messages: [{ role: 'user', content: 'hi' }],
});
});
expect(res).toMatchObject({ message: { content: 'hi' } });
expect(createMock.mock.calls.at(-1)![0].model).toBe(
'muse-spark-1.2',
);
expect(
isRouteUnhealthy(
'openrouter',
'openrouter:meta/muse-spark-1.2',
),
).toBe(true);
expect(isRouteUnhealthy('meta', 'muse-spark-1.2')).toBe(false);
}
});
it('renders a gated route beside a missing one as not found on both wire formats', async () => {
createMock
.mockRejectedValueOnce(policyBlock())
.mockRejectedValueOnce(
Object.assign(new Error('model not found'), { status: 404 }),
);
const err = await complete('muse-spark-1.2', 'openrouter');
expect(attemptsOf(err).map((a) => a.status)).toEqual([403, 404]);
const anthropic = renderAnthropicError(err, {} as Request);
const openai = renderOpenAIError(err, {} as Request);
expect(anthropic.status).toBe(404);
expect(anthropic.body).toMatchObject({
error: {
type: 'not_found_error',
message: 'Model not available: openrouter:meta/muse-spark-1.2',
},
});
expect(openai.status).toBe(404);
expect(openai.body).toMatchObject({
error: {
type: 'not_found_error',
message: 'Model not available: openrouter:meta/muse-spark-1.2',
},
});
expect(JSON.stringify([anthropic.body, openai.body])).not.toMatch(
/policy|http|request id/i,
);
});
it('leaves the same body from another provider an auth failure', async () => {
createMock.mockRejectedValue(policyBlock());
const err = await complete('muse-spark-1.2');
expect(err).toMatchObject({
statusCode: 500,
legacyCode: 'upstream_auth_failed',
});
expect(attemptsOf(err)).toEqual([
expect.objectContaining({
provider: 'meta',
status: 403,
code: 403,
}),
expect.objectContaining({
provider: 'openrouter',
status: 403,
code: 'model_not_found',
}),
]);
});
it.each([
[
'a generic 403',
{ status: 403, error: { code: 403, message: 'Forbidden' } },
500,
'upstream_auth_failed',
],
[
'a 401',
{ status: 401, error: { code: 401, message: 'User not found.' } },
500,
'upstream_auth_failed',
],
[
'a 402',
{
status: 402,
error: { code: 402, message: 'Insufficient credits' },
},
503,
'upstream_credits_exhausted',
],
[
'a 503',
{ status: 503, error: { code: 503, message: POLICY_MESSAGE } },
400,
'upstream_provider_unavailable',
],
])(
'keeps %s from OpenRouter classified as before',
async (_label, shape, statusCode, legacyCode) => {
createMock.mockRejectedValueOnce(
Object.assign(new Error(shape.error.message), shape),
);
const err = await complete('openai/o1-pro');
expect(err).toMatchObject({ statusCode, legacyCode });
},
);
});
describe('ChatCompletionDriver duplicate-model fallback', () => {
// deepseek-v4-pro is served directly by DeepSeek, by Infron, and twice by
// OpenRouter (two upstream orgs) — four routes in one bucket.
@@ -309,10 +309,17 @@ const isRateLimit = (a: ProviderAttempt) =>
a.status === 429 ||
/rate[\s_-]?limit|too many requests|quota/i.test(a.error);
const isModelAccessDenied = (a: ProviderAttempt) =>
a.status === 403 && a.code === 'model_not_found';
const isModelUnavailable = (a: ProviderAttempt) =>
a.status === 404 || isModelAccessDenied(a);
const isAuthFailure = (a: ProviderAttempt) =>
a.status === 401 ||
a.status === 403 ||
/unauthorized|forbidden|invalid api key/i.test(a.error);
!isModelAccessDenied(a) &&
(a.status === 401 ||
a.status === 403 ||
/unauthorized|forbidden|invalid api key/i.test(a.error));
const isUpstream5xx = (a: ProviderAttempt) =>
(a.status !== undefined && a.status >= 500) ||
@@ -333,7 +340,7 @@ const isUpstream5xx = (a: ProviderAttempt) =>
*/
const isRouteLevelFailure = (a: ProviderAttempt) =>
a.status === undefined ||
a.status === 404 ||
isModelUnavailable(a) ||
a.status === 408 ||
isCreditExhaustion(a) ||
isRateLimit(a) ||
@@ -373,8 +380,6 @@ const requestLevelError = (a: ProviderAttempt): HttpError =>
const isOverloaded = (a: ProviderAttempt) =>
a.status === 529 || a.upstreamType === 'overloaded_error';
const isModelUnavailable = (a: ProviderAttempt) => a.status === 404;
/**
* The HTTP status an in-band mid-stream error chunk reports — a stream
* populator failure never goes through `classifyAttempts` (there is no fallback
@@ -404,11 +409,12 @@ const streamErrorStatus = (a: ProviderAttempt): number => {
* - All credit-exhausted → 503 `upstream_credits_exhausted` (alerted)
* - All rate-limited → 429 `upstream_rate_limited` (alerted, unless every attempt
* was on a free model — see `allModelsFree`)
* - All auth failures → 500 `upstream_auth_failed` (paged: our config)
* - All auth failures, or any alongside 403 `model_not_found` → 500
* `upstream_auth_failed` (paged: our config)
* - All overloaded (529 / `overloaded_error`) → 400
* `upstream_provider_unavailable`, `upstreamStatus: 529` (no page)
* - All model-not-found (404) → 400 `upstream_bad_request`, `upstreamStatus: 404`
* (no page)
* - All model-not-found (404, or 403 `model_not_found`) → 400
* `upstream_bad_request`, `upstreamStatus: 404` (no page)
* - All upstream 5xx or dropped connections → 400 `upstream_provider_unavailable`
* (no page)
* - All upstream 4xx (other) → 400 `upstream_bad_request` (no page)
@@ -449,7 +455,10 @@ const classifyAttempts = (
noAlarm: allModelsFree,
});
}
if (attempts.every(isAuthFailure)) {
if (
attempts.some(isAuthFailure) &&
attempts.every((a) => isAuthFailure(a) || isModelAccessDenied(a))
) {
return new HttpError(500, 'AI provider authentication failed', {
legacyCode: 'upstream_auth_failed',
fields,
@@ -462,7 +471,10 @@ const classifyAttempts = (
});
}
if (attempts.every(isModelUnavailable)) {
return new HttpError(400, attempts[0].error, {
const message = attempts.some(isModelAccessDenied)
? `Model not available: ${attempts[0].model}`
: attempts[0].error;
return new HttpError(400, message, {
legacyCode: 'upstream_bad_request',
fields: { ...fields, upstreamStatus: 404 },
});
@@ -143,6 +143,23 @@ const SAMPLE_API_MODELS = [
top_provider: { max_completion_tokens: 32768 },
expiration_date: '2026-10-09',
},
{
// Batch variants aren't served on chat/completions — filtered out.
id: 'openai/gpt-6-luna:batch',
name: 'GPT-6 Luna (batch)',
created: 1714564800,
context_length: 128000,
pricing: { prompt: 0.000005, completion: 0.000015 },
top_provider: { max_completion_tokens: 16000 },
},
{
id: 'openai/gpt-6-luna:free',
name: 'GPT-6 Luna (free)',
created: 1714564800,
context_length: 128000,
pricing: { prompt: 0, completion: 0 },
top_provider: { max_completion_tokens: 16000 },
},
{
// 'openrouter/auto' is filtered out — disallowed.
id: 'openrouter/auto',
@@ -268,6 +285,18 @@ describe('OpenRouterProvider model catalog', () => {
expect(ids).not.toContain('qwen/qwen3-max');
});
it('filters out batch variants and keeps their base model', async () => {
const { provider } = makeProvider();
const ids = modelLookupNames(await provider.models());
expect(ids).not.toContain('openrouter:openai/gpt-6-luna:batch');
expect(ids).not.toContain('openai/gpt-6-luna:batch');
expect(ids).not.toContain('gpt-6-luna:batch');
expect(ids).toContain('openrouter:openai/gpt-6-luna');
expect(ids).toContain('gpt-6-luna');
expect(ids).toContain('openrouter:openai/gpt-6-luna:free');
expect(ids).toContain('gpt-6-luna:free');
});
it('coerces string prices and normalizes conditional tiers', async () => {
const { provider } = makeProvider();
const models = await provider.models();
@@ -610,6 +639,126 @@ describe('OpenRouterProvider.complete request shape', () => {
});
});
describe('OpenRouterProvider account policy block', () => {
const POLICY_MESSAGE =
'Policy Violation: this user has been blocked for a previous policy violation. Learn more: https://openrouter.ai/docs/policy (request id: req-secret)';
// Shaped like the OpenAI SDK's APIError for OpenRouter's error body.
const apiError = (status: number, body: Record<string, unknown>) =>
Object.assign(new Error(`${status} ${String(body.message)}`), {
status,
error: body,
code: body.code,
});
const complete = () =>
withTestActor(() =>
makeProvider().provider.complete({
model: 'openrouter:openai/gpt-6-luna',
messages: [{ role: 'user', content: 'hi' }],
}),
);
const expectModelUnavailable = async () => {
const err = await complete().catch((e: unknown) => e);
expect(err).toMatchObject({
name: 'UpstreamResponseError',
status: 403,
code: 'model_not_found',
provider: 'openrouter',
message: 'Model not available: openrouter:openai/gpt-6-luna',
});
expect(JSON.stringify(err)).not.toMatch(/policy|http|request id/i);
expect((err as Error).message).not.toMatch(/policy|http/i);
};
it('reports a numeric 403 policy block as the model being unavailable', async () => {
createMock.mockRejectedValueOnce(
apiError(403, { code: 403, message: POLICY_MESSAGE }),
);
await expectModelUnavailable();
expect(createMock).toHaveBeenCalledTimes(1);
});
it('reports the in-band 403 policy block of a 200 with no choices the same way', async () => {
createMock.mockResolvedValueOnce({
error: { code: 403, message: POLICY_MESSAGE },
});
await expectModelUnavailable();
expect(recordSpy).not.toHaveBeenCalled();
});
it.each([
['a generic 403', apiError(403, { code: 403, message: 'Forbidden' })],
[
'a key limit 403',
apiError(403, {
code: 403,
message: 'Key limit exceeded (daily limit).',
}),
],
[
'a moderation 403',
apiError(403, {
code: 403,
message:
'Your chosen model requires moderation and your input was flagged',
metadata: { reasons: ['violence'] },
}),
],
[
'a similar 403 wording',
apiError(403, {
code: 403,
message: 'Policy Violation: this key has been blocked.',
}),
],
[
'the policy wording without its numeric code',
apiError(403, { message: POLICY_MESSAGE }),
],
[
'the policy wording on a 401',
apiError(401, { code: 401, message: POLICY_MESSAGE }),
],
[
'the policy wording on a 503',
apiError(503, { code: 403, message: POLICY_MESSAGE }),
],
[
'a 402 credit exhaustion',
apiError(402, { code: 402, message: 'Insufficient credits' }),
],
])('rethrows %s unchanged', async (_label, thrown) => {
createMock.mockRejectedValueOnce(thrown);
await expect(complete()).rejects.toBe(thrown);
});
it.each([
['no error body', {}, 'no completion was returned'],
[
'an in-band error with another code',
{ error: { code: 401, message: POLICY_MESSAGE } },
POLICY_MESSAGE,
],
[
'an in-band error with other wording',
{ error: { code: 403, message: 'Provider returned error' } },
'Provider returned error',
],
])(
'keeps a no-choice completion with %s as bad_response',
async (_label, completion, message) => {
createMock.mockResolvedValueOnce(completion);
await expect(complete()).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'bad_response',
message,
});
},
);
});
// ── Non-stream completion: cost calculator branches ─────────────────
describe('OpenRouterProvider.complete non-stream output', () => {
@@ -19,7 +19,8 @@
import axios from 'axios';
import { OpenAI } from 'openai';
import { HttpError } from '../../../../core/http/HttpError.js';
import { HttpError, isHttpError } from '../../../../core/http/HttpError.js';
import { UpstreamResponseError } from '../../../util/upstreamErrors.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type { IChatModel, ICompleteArguments } from '../../types.js';
import { cachedRemoteCatalog } from '../../utils/cachedRemoteCatalog.js';
@@ -37,6 +38,29 @@ type OpenrouterUsage = OpenAI.Completions.CompletionUsage & {
cost?: number;
};
const POLICY_BLOCK_MESSAGE =
'Policy Violation: this user has been blocked for a previous policy violation.';
const upstreamErrorBody = (err: unknown): unknown => {
if (isHttpError(err)) {
return err.legacyCode === 'bad_response' ? err.cause : undefined;
}
const e = err as { status?: unknown; error?: unknown } | undefined;
return e?.status === 403 ? e.error : undefined;
};
const isPolicyBlock = (err: unknown): boolean => {
const { code, message } = (upstreamErrorBody(err) ?? {}) as {
code?: unknown;
message?: unknown;
};
return (
code === 403 &&
typeof message === 'string' &&
message.startsWith(POLICY_BLOCK_MESSAGE)
);
};
export class OpenRouterProvider extends OpenAICompatProvider {
#apiBaseUrl: string;
@@ -78,7 +102,17 @@ export class OpenRouterProvider extends OpenAICompatProvider {
},
);
}
return super.complete(args, resolved);
try {
return await super.complete(args, resolved);
} catch (e) {
if (!isPolicyBlock(e)) throw e;
throw new UpstreamResponseError(
`Model not available: ${args.model}`,
403,
'openrouter',
'model_not_found',
);
}
}
override async models(): Promise<IChatModel[]> {
@@ -105,7 +139,8 @@ export class OpenRouterProvider extends OpenAICompatProvider {
for (const model of models) {
if (
typeof model.id !== 'string' ||
model.id.includes('openrouter/auto')
model.id.includes('openrouter/auto') ||
model.id.endsWith(':batch')
) {
continue;
}
@@ -907,7 +907,7 @@ export const handle_completion_output = async (
typeof upstreamMessage === 'string' && upstreamMessage
? upstreamMessage
: 'no completion was returned',
{ legacyCode: 'bad_response' },
{ legacyCode: 'bad_response', cause: completion?.error },
);
}