Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions apps/cli/ai/providers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { password } from '@inquirer/prompts';
import { validateAnthropicApiKey } from '@studio/common/ai/anthropic-key';
import {
AI_MODELS,
DEFAULT_MODEL,
Expand Down Expand Up @@ -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 {
Expand Down
51 changes: 51 additions & 0 deletions packages/common/ai/anthropic-key.ts
Original file line number Diff line number Diff line change
@@ -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.' ),
};
}
61 changes: 61 additions & 0 deletions packages/common/ai/tests/anthropic-key.test.ts
Original file line number Diff line number Diff line change
@@ -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',
} );
} );
} );
Loading