Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2f57a91
feat: add Anthropic API key form to agentic UI AI settings
sejas Aug 11, 2026
3ceed62
feat: support Anthropic API key settings in desktop agentic UI
sejas Aug 11, 2026
bd2b7e8
feat: validate Anthropic API key in CLI api-key and provider commands
sejas Aug 11, 2026
92af746
Merge branch 'validate-anthropic-api-key-cli' into add-anthropic-api-…
sejas Aug 11, 2026
94ab5f2
feat: validate Anthropic key on save; move input below description
sejas Aug 11, 2026
d275ea5
feat: switch Anthropic key UI to a provider toggle with autosaved key
sejas Aug 11, 2026
918790b
feat: validate the Anthropic key before saving it in AI settings
sejas Aug 11, 2026
4fb0064
refactor: dedupe cli.json primitives, debounce hook, error handling
sejas Aug 11, 2026
3213240
update: clarify Anthropic billing in AI settings copy
sejas Aug 11, 2026
81e3211
update: shorten Anthropic key description
sejas Aug 11, 2026
464e3cb
refactor: move AI provider settings from cli.json to shared.json
sejas Aug 11, 2026
7c2ac48
Merge remote-tracking branch 'origin/trunk' into add-anthropic-api-ke…
sejas Aug 12, 2026
859d500
fix: keep provider model ids narrowed to AiModelId
sejas Aug 12, 2026
bfca7df
fix: show a red border on the Anthropic API key field when saving fails
sejas Aug 12, 2026
8425c52
Merge remote-tracking branch 'origin/trunk' into add-anthropic-api-ke…
sejas Aug 13, 2026
ef9b3ca
test: remove a duplicate AiPanel case and rename a misleading one
sejas Aug 13, 2026
75312ff
fix: move AI settings from cli.json to shared.json in a CLI migration
sejas Aug 13, 2026
b26ecb3
add: studio_setting_ai_provider_change Tracks event
sejas Aug 13, 2026
67c359c
Merge remote-tracking branch 'origin/trunk' into add-anthropic-api-ke…
sejas Aug 13, 2026
2fd553f
Merge remote-tracking branch 'origin/trunk' into add-anthropic-api-ke…
sejas Aug 13, 2026
eaab3b8
Merge remote-tracking branch 'origin/trunk' into add-anthropic-api-ke…
sejas Aug 13, 2026
a5f9498
fix: preview short Anthropic keys by tail only and tighten comments
sejas Aug 13, 2026
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
9 changes: 6 additions & 3 deletions apps/cli/ai/auth.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
persistSelectedAiProvider,
readSelectedAiProvider,
} from '@studio/common/ai/settings-store';
import {
AI_PROVIDER_PRIORITY,
DEFAULT_AI_PROVIDER,
Expand All @@ -6,7 +10,6 @@ import {
type AiProviderId,
type ResolveAiEnvironmentOptions,
} from 'cli/ai/providers';
import { readCliConfig, updateCliConfigWithPartial } from 'cli/lib/cli-config/core';

async function getPreferredReadyProvider(
exclude?: AiProviderId
Expand Down Expand Up @@ -55,7 +58,7 @@ export async function resolveInitialAiProvider(): Promise< AiProviderId > {
return 'wpcom';
}

const { aiProvider: savedProvider } = await readCliConfig();
const savedProvider = await readSelectedAiProvider();
if ( savedProvider ) {
const definition = getAiProviderDefinition( savedProvider );
if (
Expand All @@ -79,7 +82,7 @@ export async function resolveInitialAiProvider(): Promise< AiProviderId > {
}

export async function saveSelectedAiProvider( provider: AiProviderId ): Promise< void > {
await updateCliConfigWithPartial( { aiProvider: provider } );
await persistSelectedAiProvider( provider );
}

export async function prepareAiProvider(
Expand Down
59 changes: 24 additions & 35 deletions apps/cli/ai/providers.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
import { password } from '@inquirer/prompts';
import { validateAnthropicApiKey } from '@studio/common/ai/anthropic-key';
import { DEFAULT_MODEL, type AiModelId } from '@studio/common/ai/models';
import {
AI_MODELS,
DEFAULT_MODEL,
type AiModelFamily,
type AiModelId,
} from '@studio/common/ai/models';
AI_PROVIDER_IDS,
DEFAULT_AI_PROVIDER,
getAiProviderModels,
type AiProviderId,
} from '@studio/common/ai/providers';
import { persistAnthropicApiKey, readAnthropicApiKey } from '@studio/common/ai/settings-store';
import { readAuthToken } from '@studio/common/lib/shared-config';
import { __ } from '@wordpress/i18n';
import { readCliConfig, updateCliConfigWithPartial } from 'cli/lib/cli-config/core';
import { LoggerError } from 'cli/logger';

export const AI_PROVIDERS = {
export const AI_PROVIDERS: Record< AiProviderId, string > = {
wpcom: 'WordPress.com',
'anthropic-api-key': 'Anthropic · API key',
} as const;

export type AiProviderId = keyof typeof AI_PROVIDERS;
};

export const DEFAULT_AI_PROVIDER: AiProviderId = 'wpcom';
export const AI_PROVIDER_PRIORITY: AiProviderId[] = [ 'wpcom', 'anthropic-api-key' ];
export type { AiProviderId };
export { DEFAULT_AI_PROVIDER };
// Fallback order when the configured provider is unavailable; declaration
// order of the canonical id list.
export const AI_PROVIDER_PRIORITY: readonly AiProviderId[] = AI_PROVIDER_IDS;

const DEFAULT_WPCOM_AI_GATEWAY_BASE_URL = 'https://public-api.wordpress.com/wpcom/v2/ai-api-proxy';
// The wpcom AI proxy maps feature slugs to upstream providers. Historically
Expand All @@ -36,14 +38,9 @@ export interface ResolveAiEnvironmentOptions {
export interface AiProviderDefinition {
id: AiProviderId;
autoFallbackWhenUnavailable: boolean;
/**
* Which model families this provider can service. `wpcom` relays both
* Anthropic and OpenAI wire formats through the same proxy; direct-API
* providers are restricted to their own family. `availableModels` and
* `defaultModel` are derived from this and kept on the definition so
* callers don't have to filter AI_MODELS themselves.
*/
readonly supportedModelFamilies: readonly AiModelFamily[];
// Derived from the provider's model families (see
// `@studio/common/ai/providers`), kept on the definition so callers don't
// have to filter AI_MODELS themselves.
readonly availableModels: readonly AiModelId[];
readonly defaultModel: AiModelId;
supportsModel( model: AiModelId ): boolean;
Expand All @@ -53,17 +50,12 @@ export interface AiProviderDefinition {
resolveEnv: ( options?: ResolveAiEnvironmentOptions ) => Promise< Record< string, string > >;
}

/**
* Fills in `availableModels`, `defaultModel`, and `supportsModel` from the
* declared `supportedModelFamilies` so each provider literal below only has to
* state its family allowlist.
*/
// Fills in `availableModels`, `defaultModel`, and `supportsModel` from the
// provider id's model families.
function defineProvider(
partial: Omit< AiProviderDefinition, 'availableModels' | 'defaultModel' | 'supportsModel' >
): AiProviderDefinition {
const availableModels: AiModelId[] = AI_MODELS.filter( ( model ) =>
partial.supportedModelFamilies.includes( model.family )
).map( ( model ) => model.id );
const availableModels = getAiProviderModels( partial.id ).map( ( model ) => model.id );
return {
...partial,
availableModels,
Expand All @@ -77,7 +69,7 @@ function defineProvider(
async function resolveAnthropicApiKey( options?: {
force?: boolean;
} ): Promise< string | undefined > {
const { anthropicApiKey: savedKey } = await readCliConfig();
const savedKey = await readAnthropicApiKey();
if ( savedKey && ! options?.force ) {
// Re-prompt only when Anthropic definitively rejects the saved key;
// an unreachable API must not lock the user out of their provider.
Expand All @@ -101,7 +93,7 @@ async function resolveAnthropicApiKey( options?: {
} );

const trimmedKey = apiKey.trim();
await updateCliConfigWithPartial( { anthropicApiKey: trimmedKey } );
await persistAnthropicApiKey( trimmedKey );
return trimmedKey;
}

Expand Down Expand Up @@ -152,7 +144,6 @@ const AI_PROVIDER_DEFINITIONS: Record< AiProviderId, AiProviderDefinition > = {
wpcom: defineProvider( {
id: 'wpcom',
autoFallbackWhenUnavailable: true,
supportedModelFamilies: [ 'anthropic', 'openai' ],
isVisible: async () => true,
isReady: async () => hasInlineWpcomAuth() || ( await hasValidWpcomAuth() ),
prepare: async () => {
Expand Down Expand Up @@ -205,17 +196,15 @@ const AI_PROVIDER_DEFINITIONS: Record< AiProviderId, AiProviderDefinition > = {
'anthropic-api-key': defineProvider( {
id: 'anthropic-api-key',
autoFallbackWhenUnavailable: false,
supportedModelFamilies: [ 'anthropic' ],
isVisible: async () => true,
isReady: async () => {
const { anthropicApiKey } = await readCliConfig();
return Boolean( anthropicApiKey );
return Boolean( await readAnthropicApiKey() );
},
prepare: async ( options ) => {
await resolveAnthropicApiKey( options );
},
resolveEnv: async () => {
const { anthropicApiKey: apiKey } = await readCliConfig();
const apiKey = await readAnthropicApiKey();
if ( ! apiKey ) {
throw new LoggerError(
__(
Expand Down
7 changes: 2 additions & 5 deletions apps/cli/ai/sessions/context.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import { resolveSessionModel, type AiModelId } from '@studio/common/ai/models';
import { isAiProviderId } from '@studio/common/ai/providers';
import { isStudioCustomEntryOfType } from '@studio/common/ai/sessions/entry-types';
import { AI_PROVIDERS, type AiProviderId } from 'cli/ai/providers';
import type { LoadedAiSession } from '@studio/common/ai/sessions/types';

function isAiProviderId( value: string ): value is AiProviderId {
return Object.prototype.hasOwnProperty.call( AI_PROVIDERS, value );
}
import type { AiProviderId } from 'cli/ai/providers';

export interface ResumeSessionContext {
sessionId?: string;
Expand Down
61 changes: 22 additions & 39 deletions apps/cli/ai/tests/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { password } from '@inquirer/prompts';
import {
persistAnthropicApiKey,
readAnthropicApiKey,
readSelectedAiProvider,
} from '@studio/common/ai/settings-store';
import { readAuthToken } from '@studio/common/lib/shared-config';
import { vi } from 'vitest';
import {
Expand All @@ -9,7 +14,6 @@ import {
resolveInitialAiProvider,
resolveUnavailableAiProvider,
} from 'cli/ai/auth';
import { readCliConfig, updateCliConfigWithPartial } from 'cli/lib/cli-config/core';
import { LoggerError } from 'cli/logger';

vi.mock( '@inquirer/prompts', () => ( {
Expand All @@ -20,16 +24,17 @@ vi.mock( '@studio/common/lib/shared-config', () => ( {
readAuthToken: vi.fn(),
} ) );

vi.mock( 'cli/lib/cli-config/core', () => ( {
readCliConfig: vi.fn().mockResolvedValue( { version: 1, sites: [] } ),
updateCliConfigWithPartial: vi.fn(),
vi.mock( '@studio/common/ai/settings-store', () => ( {
readAnthropicApiKey: vi.fn(),
readSelectedAiProvider: vi.fn(),
persistAnthropicApiKey: vi.fn(),
persistSelectedAiProvider: vi.fn(),
} ) );

describe( 'AI auth helpers', () => {
beforeEach( () => {
vi.resetAllMocks();
vi.stubGlobal( '__STUDIO_CLI_VERSION__', '1.2.3' );
vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } );
delete process.env.WPCOM_AI_PROXY_BASE_URL;
} );

Expand All @@ -38,23 +43,18 @@ describe( 'AI auth helpers', () => {
} );

it( 'uses the saved Anthropic API key when provider is Anthropic API key', async () => {
vi.mocked( readCliConfig ).mockResolvedValue( {
version: 1,
sites: [],
snapshots: [],
anthropicApiKey: 'saved-key',
} );
vi.mocked( readAnthropicApiKey ).mockResolvedValue( 'saved-key' );

const env = await resolveAiEnvironment( 'anthropic-api-key' );

expect( env.ANTHROPIC_API_KEY ).toBe( 'saved-key' );
expect( env.ANTHROPIC_BASE_URL ).toBeUndefined();
expect( env.ANTHROPIC_AUTH_TOKEN ).toBeUndefined();
expect( updateCliConfigWithPartial ).not.toHaveBeenCalled();
expect( persistAnthropicApiKey ).not.toHaveBeenCalled();
} );

it( 'requires a saved Anthropic API key in API key mode', async () => {
vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } );
vi.mocked( readAnthropicApiKey ).mockResolvedValue( undefined );

await expect( resolveAiEnvironment( 'anthropic-api-key' ) ).rejects.toBeInstanceOf(
LoggerError
Expand All @@ -63,30 +63,23 @@ describe( 'AI auth helpers', () => {
} );

it( 'prompts for the API key immediately when preparing the API key provider', async () => {
vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } );
vi.mocked( readAnthropicApiKey ).mockResolvedValue( undefined );
vi.mocked( password ).mockResolvedValue( 'prompted-key' );

await prepareAiProvider( 'anthropic-api-key' );

expect( password ).toHaveBeenCalledOnce();
expect( updateCliConfigWithPartial ).toHaveBeenCalledWith( {
anthropicApiKey: 'prompted-key',
} );
expect( persistAnthropicApiKey ).toHaveBeenCalledWith( 'prompted-key' );
} );

it( 'can force re-entering the API key even when one is already saved', async () => {
vi.mocked( readCliConfig ).mockResolvedValue( {
version: 1,
sites: [],
snapshots: [],
anthropicApiKey: 'saved-key',
} );
vi.mocked( readAnthropicApiKey ).mockResolvedValue( 'saved-key' );
vi.mocked( password ).mockResolvedValue( 'updated-key' );

await prepareAiProvider( 'anthropic-api-key', { force: true } );

expect( password ).toHaveBeenCalledOnce();
expect( updateCliConfigWithPartial ).toHaveBeenCalledWith( { anthropicApiKey: 'updated-key' } );
expect( persistAnthropicApiKey ).toHaveBeenCalledWith( 'updated-key' );
} );

it( 'lists available providers', async () => {
Expand Down Expand Up @@ -133,20 +126,15 @@ describe( 'AI auth helpers', () => {
} );

it( 'prefers the saved provider', async () => {
vi.mocked( readCliConfig ).mockResolvedValue( {
version: 1,
sites: [],
snapshots: [],
aiProvider: 'anthropic-api-key',
anthropicApiKey: 'key',
} );
vi.mocked( readSelectedAiProvider ).mockResolvedValue( 'anthropic-api-key' );
vi.mocked( readAnthropicApiKey ).mockResolvedValue( 'key' );

await expect( resolveInitialAiProvider() ).resolves.toBe( 'anthropic-api-key' );
expect( readAuthToken ).not.toHaveBeenCalled();
} );

it( 'defaults to WP.com when no provider is saved and a valid WP.com token exists', async () => {
vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } );
vi.mocked( readSelectedAiProvider ).mockResolvedValue( undefined );
vi.mocked( readAuthToken ).mockResolvedValue( {
accessToken: 'wpcom-token',
displayName: 'User',
Expand All @@ -160,7 +148,7 @@ describe( 'AI auth helpers', () => {
} );

it( 'falls back to default provider when no other auth is available', async () => {
vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } );
vi.mocked( readSelectedAiProvider ).mockResolvedValue( undefined );
vi.mocked( readAuthToken ).mockResolvedValue( null );

await expect( resolveInitialAiProvider() ).resolves.toBe( 'wpcom' );
Expand All @@ -184,12 +172,7 @@ describe( 'AI auth helpers', () => {

it( 'resolves a fallback provider only for providers that auto-fallback', async () => {
vi.mocked( readAuthToken ).mockResolvedValue( null );
vi.mocked( readCliConfig ).mockResolvedValue( {
version: 1,
sites: [],
snapshots: [],
anthropicApiKey: 'saved-key',
} );
vi.mocked( readAnthropicApiKey ).mockResolvedValue( 'saved-key' );

await expect( resolveUnavailableAiProvider( 'wpcom' ) ).resolves.toBe( 'anthropic-api-key' );
await expect( resolveUnavailableAiProvider( 'anthropic-api-key' ) ).resolves.toBeUndefined();
Expand Down
7 changes: 3 additions & 4 deletions apps/cli/commands/ai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type AiModelId,
} from '@studio/common/ai/models';
import { getAgentEndTurnResult } from '@studio/common/ai/session-events';
import { readAnthropicApiKey, readSelectedAiProvider } from '@studio/common/ai/settings-store';
import {
buildSkillInvocationPrompt,
resolveSkillFromPrompt,
Expand Down Expand Up @@ -47,7 +48,6 @@ import { setLocalSiteSelectedCallback } from 'cli/ai/site-selection';
import { getActiveSlashCommands, type SlashCommandContext } from 'cli/ai/slash-commands';
import { AiChatUI } from 'cli/ai/ui';
import { runCommand as runLoginCommand } from 'cli/commands/auth/login';
import { readCliConfig } from 'cli/lib/cli-config/core';
import { findSiteByFolder, findSiteById } from 'cli/lib/cli-config/sites';
import { disconnectFromDaemon } from 'cli/lib/daemon-client';
import { isSiteRunning } from 'cli/lib/site-utils';
Expand Down Expand Up @@ -368,8 +368,7 @@ export async function runCommand( options: {
}
}

const config = await readCliConfig();
let showCapabilitiesOnConnect = ! config.aiProvider;
let showCapabilitiesOnConnect = ( await readSelectedAiProvider() ) === undefined;

// Studio Code Desktop defaults to WordPress.com provider.
if ( isJsonMode && showCapabilitiesOnConnect ) {
Expand Down Expand Up @@ -457,7 +456,7 @@ export async function runCommand( options: {
} else {
ui.setStatusMessage( __( 'Use /login to authenticate to WordPress.com' ) );
}
} else if ( currentProvider === 'anthropic-api-key' && ! config.anthropicApiKey ) {
} else if ( currentProvider === 'anthropic-api-key' && ! ( await readAnthropicApiKey() ) ) {
ui.showInfo( __( 'No Anthropic API key saved. Use /api-key to enter one.' ) );
}

Expand Down
Loading