From 2c07caa02c409a941e84bb01ca3344463cd06aef Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Thu, 5 Nov 2026 06:45:00 +0000 Subject: [PATCH] fix: reject provider credentials marked non-retryable --- .../provider-validation.native.test.ts | 89 +++++++++++++++++++ .../__tests__/non-task-provider-usage.test.ts | 32 +++++++ .../src/server/non-task-provider-usage.ts | 18 ++-- 3 files changed, 131 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/trpc/commands/task-models/provider-validation.native.test.ts diff --git a/apps/web/src/trpc/commands/task-models/provider-validation.native.test.ts b/apps/web/src/trpc/commands/task-models/provider-validation.native.test.ts new file mode 100644 index 000000000..0768d29de --- /dev/null +++ b/apps/web/src/trpc/commands/task-models/provider-validation.native.test.ts @@ -0,0 +1,89 @@ +import { createServer } from 'node:http'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { assertInferenceProviderConnection } from './provider-validation'; + +// Opt in to the real pinned OpenCode helper. The provider is a local HTTP +// fixture; no live credentials, paid inference, or persisted configuration. +describe.skipIf(process.env.ROOMOTE_TEST_OPENCODE_PROVIDER_VALIDATION !== '1')( + 'native inference provider validation', + () => { + let home: string; + let status = 401; + const requests: string[] = []; + const provider = createServer((req, res) => { + requests.push(`${req.method} ${req.url}`); + req.resume(); + res.writeHead(status, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + error: { + type: + status === 402 ? 'insufficient_quota' : 'authentication_error', + message: status === 402 ? 'Insufficient credits.' : 'Rejected.', + }, + }), + ); + }); + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'roomote-provider-validation-')); + await new Promise((resolve) => + provider.listen(0, '127.0.0.1', resolve), + ); + const address = provider.address(); + if (!address || typeof address === 'string') + throw new Error('No local provider'); + vi.stubEnv('HOME', home); + vi.stubEnv('XDG_CONFIG_HOME', join(home, 'config')); + vi.stubEnv('XDG_DATA_HOME', join(home, 'data')); + vi.stubEnv('XDG_CACHE_HOME', join(home, 'cache')); + vi.stubEnv('ANTHROPIC_BASE_URL', `http://127.0.0.1:${address.port}`); + vi.stubEnv('OPENCODE_DISABLE_AUTOUPDATE', '1'); + vi.stubEnv('OPENCODE_DISABLE_MODELS_FETCH', '1'); + }); + + afterAll(async () => { + vi.unstubAllEnvs(); + provider.closeAllConnections(); + await new Promise((resolve) => provider.close(() => resolve())); + if (home) await rm(home, { recursive: true, force: true }); + }); + + const validate = () => + assertInferenceProviderConnection({ + providerLabel: 'Anthropic', + providerEnvVarNames: ['ANTHROPIC_API_KEY'], + modelId: 'anthropic/claude-sonnet-4-5', + persistedEnv: {}, + credentialValues: [ + { name: 'ANTHROPIC_API_KEY', value: 'synthetic-local-only' }, + ], + }); + + it.each([401, 403])( + 'blocks a key rejected with HTTP %s', + async (code) => { + status = code; + requests.length = 0; + await expect(validate()).rejects.toMatchObject({ + code: 'invalid_credentials', + retryable: false, + }); + expect(requests).toEqual(['POST /messages']); + }, + 45_000, + ); + + it('keeps exhausted credits as a non-blocking warning', async () => { + status = 402; + requests.length = 0; + await expect(validate()).resolves.toMatchObject({ + code: 'insufficient_credits', + }); + expect(requests).toEqual(['POST /messages']); + }, 45_000); + }, +); diff --git a/packages/cloud-agents/src/server/__tests__/non-task-provider-usage.test.ts b/packages/cloud-agents/src/server/__tests__/non-task-provider-usage.test.ts index fa76a3207..e76cfcecb 100644 --- a/packages/cloud-agents/src/server/__tests__/non-task-provider-usage.test.ts +++ b/packages/cloud-agents/src/server/__tests__/non-task-provider-usage.test.ts @@ -3312,6 +3312,38 @@ describe('resolveOpenCodeSmallModel', () => { ).toMatchObject({ retryable: false }); }); + it.each([401, 403])( + 'retains the credential failure category for non-retryable HTTP %s', + async (statusCode) => { + const { classifyNonTaskInferenceError } = + await import('../non-task-provider-usage.js'); + + expect( + classifyNonTaskInferenceError({ + name: 'APIError', + data: { message: 'Rejected', statusCode, isRetryable: false }, + }), + ).toMatchObject({ reason: 'invalid_credentials', retryable: false }); + }, + ); + + it('preserves explicitly non-retryable gateway and model-access failures', async () => { + const { classifyNonTaskInferenceError } = + await import('../non-task-provider-usage.js'); + + for (const data of [ + { statusCode: 403, responseBody: 'Blocked' }, + { statusCode: 404, message: 'Unknown model' }, + ]) { + expect( + classifyNonTaskInferenceError({ + name: 'APIError', + data: { ...data, isRetryable: false }, + }), + ).toMatchObject({ reason: 'provider_error', retryable: false }); + } + }); + it.each(['fetch failed', 'request terminated'])( 'classifies a wrapped fetch reset with root message %j', async (message) => { diff --git a/packages/cloud-agents/src/server/non-task-provider-usage.ts b/packages/cloud-agents/src/server/non-task-provider-usage.ts index c3b32881c..672671882 100644 --- a/packages/cloud-agents/src/server/non-task-provider-usage.ts +++ b/packages/cloud-agents/src/server/non-task-provider-usage.ts @@ -3037,6 +3037,16 @@ export function classifyNonTaskInferenceError( }; } + // The SDK marks credential rejections non-retryable too. Preserve their + // specific category so pre-save validation can reject a known-bad key. + if (!gatewayBlocked && (statusCode === 401 || statusCode === 403)) { + return { + message: 'The inference provider rejected these credentials.', + reason: 'invalid_credentials', + retryable: false, + }; + } + if (isInferenceErrorExplicitlyNonRetryable(inferenceError)) { return { message: 'The inference provider rejected the request.', @@ -3109,14 +3119,6 @@ export function classifyNonTaskInferenceError( }; } - if (statusCode === 401 || statusCode === 403) { - return { - message: 'The inference provider rejected these credentials.', - reason: 'invalid_credentials', - retryable: false, - }; - } - if (statusCode === 404) { return { message: 'The selected model is unavailable with these credentials.',