diff --git a/apps/cli/ai/providers.ts b/apps/cli/ai/providers.ts index 8be5cf620d..746d8f6dac 100644 --- a/apps/cli/ai/providers.ts +++ b/apps/cli/ai/providers.ts @@ -1,4 +1,5 @@ import { password } from '@inquirer/prompts'; +import { validateAnthropicApiKey } from '@studio/common/ai/anthropic-key'; import { AI_MODELS, DEFAULT_MODEL, @@ -78,22 +79,30 @@ async function resolveAnthropicApiKey( options?: { } ): Promise< string | undefined > { const { anthropicApiKey: savedKey } = await readCliConfig(); if ( savedKey && ! options?.force ) { - return savedKey; + // Re-prompt only when Anthropic definitively rejects the saved key; + // an unreachable API must not lock the user out of their provider. + const validation = await validateAnthropicApiKey( savedKey ); + if ( validation.status !== 'invalid' ) { + return savedKey; + } } const apiKey = await password( { message: __( 'Enter your Anthropic API key (will be saved for future use):' ), mask: '*', - validate: ( value ) => { - if ( ! value.trim() ) { + validate: async ( value ) => { + const trimmed = value.trim(); + if ( ! trimmed ) { return __( 'API key is required' ); } - return true; + const validation = await validateAnthropicApiKey( trimmed ); + return validation.status === 'invalid' ? validation.message : true; }, } ); - await updateCliConfigWithPartial( { anthropicApiKey: apiKey } ); - return apiKey; + const trimmedKey = apiKey.trim(); + await updateCliConfigWithPartial( { anthropicApiKey: trimmedKey } ); + return trimmedKey; } function getStudioUserAgent(): string { diff --git a/packages/common/ai/anthropic-key.ts b/packages/common/ai/anthropic-key.ts new file mode 100644 index 0000000000..edad8dff74 --- /dev/null +++ b/packages/common/ai/anthropic-key.ts @@ -0,0 +1,51 @@ +import { __ } from '@wordpress/i18n'; + +const ANTHROPIC_MODELS_URL = 'https://api.anthropic.com/v1/models?limit=1'; +const ANTHROPIC_API_VERSION = '2023-06-01'; +const VALIDATION_TIMEOUT_MS = 10_000; + +export type AnthropicKeyValidation = + | { status: 'valid' } + | { status: 'invalid'; message: string } + | { status: 'unverifiable'; message: string }; + +/** + * Checks an Anthropic API key against the live API with a cheap authenticated + * request (listing models consumes no tokens). + * + * Only a definitive authentication failure reports `invalid`; rate limits, + * server errors, and network failures report `unverifiable` so callers can + * decide whether to accept the key anyway (e.g. saving while offline) rather + * than locking the user out on an Anthropic outage. + */ +export async function validateAnthropicApiKey( apiKey: string ): Promise< AnthropicKeyValidation > { + let response: Response; + try { + response = await fetch( ANTHROPIC_MODELS_URL, { + headers: { + 'x-api-key': apiKey, + 'anthropic-version': ANTHROPIC_API_VERSION, + }, + signal: AbortSignal.timeout( VALIDATION_TIMEOUT_MS ), + } ); + } catch { + return { + status: 'unverifiable', + message: __( 'Could not reach Anthropic to verify the API key.' ), + }; + } + + if ( response.ok ) { + return { status: 'valid' }; + } + if ( response.status === 401 || response.status === 403 ) { + return { + status: 'invalid', + message: __( 'Anthropic rejected this API key. Check the key and try again.' ), + }; + } + return { + status: 'unverifiable', + message: __( 'Could not verify the API key with Anthropic.' ), + }; +} diff --git a/packages/common/ai/tests/anthropic-key.test.ts b/packages/common/ai/tests/anthropic-key.test.ts new file mode 100644 index 0000000000..735fea8726 --- /dev/null +++ b/packages/common/ai/tests/anthropic-key.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { validateAnthropicApiKey } from '../anthropic-key'; + +describe( 'validateAnthropicApiKey', () => { + afterEach( () => { + vi.unstubAllGlobals(); + } ); + + function stubFetch( response: Partial< Response > | Error ) { + const fetchMock = + response instanceof Error + ? vi.fn().mockRejectedValue( response ) + : vi.fn().mockResolvedValue( response ); + vi.stubGlobal( 'fetch', fetchMock ); + return fetchMock; + } + + it( 'reports a working key as valid and sends the Anthropic auth headers', async () => { + const fetchMock = stubFetch( { ok: true, status: 200 } ); + + await expect( validateAnthropicApiKey( 'sk-ant-test-1234' ) ).resolves.toEqual( { + status: 'valid', + } ); + expect( fetchMock ).toHaveBeenCalledWith( + 'https://api.anthropic.com/v1/models?limit=1', + expect.objectContaining( { + headers: expect.objectContaining( { 'x-api-key': 'sk-ant-test-1234' } ), + } ) + ); + } ); + + it.each( [ 401, 403 ] )( + 'reports an authentication failure (%d) as invalid', + async ( status ) => { + stubFetch( { ok: false, status } ); + + await expect( validateAnthropicApiKey( 'sk-ant-bad' ) ).resolves.toMatchObject( { + status: 'invalid', + } ); + } + ); + + it.each( [ 429, 500 ] )( + 'reports a non-auth API failure (%d) as unverifiable, not invalid', + async ( status ) => { + stubFetch( { ok: false, status } ); + + await expect( validateAnthropicApiKey( 'sk-ant-test-1234' ) ).resolves.toMatchObject( { + status: 'unverifiable', + } ); + } + ); + + it( 'reports a network failure as unverifiable', async () => { + stubFetch( new Error( 'network down' ) ); + + await expect( validateAnthropicApiKey( 'sk-ant-test-1234' ) ).resolves.toMatchObject( { + status: 'unverifiable', + } ); + } ); +} );