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
13 changes: 13 additions & 0 deletions extensions/claude/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,19 @@
"scope": "InferenceProviderConnectionFactory",
"description": "Enter your Claude API key (ANTHROPIC_API_KEY)",
"format": "password"
},
"claude.connection._type": {
"type": "string",
"scope": "InferenceProviderConnection",
"description": "Claude provider type",
"hidden": true
},
"claude.connection.token": {
"type": "string",
"scope": "InferenceProviderConnection",
"description": "API key secret reference",
"format": "password",
"hidden": true
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion extensions/claude/src/claude-extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import { InversifyBinding } from '/@/inject/inversify-binding';
import { ClaudeInferenceManager } from '/@/manager/claude-inference-manager';
import { ClaudeSkillsManager } from '/@/manager/claude-skills-manager';

export const PROVIDER_ID = 'claude';

export class ClaudeExtension {
#extensionContext: ExtensionContext;

Expand All @@ -49,7 +51,7 @@ export class ClaudeExtension {
const claudeProvider = provider.createProvider({
name: 'Claude',
status: 'unknown',
id: 'claude',
id: PROVIDER_ID,
images: providerImages,
});

Expand Down
70 changes: 60 additions & 10 deletions extensions/claude/src/manager/claude-inference-manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ import { randomUUID } from 'node:crypto';
import { type AnthropicProvider, createAnthropic } from '@ai-sdk/anthropic';
import AnthropicClient from '@anthropic-ai/sdk';
import type { ModelInfo } from '@anthropic-ai/sdk/resources';
import type { CancellationToken, Disposable, Logger, Provider, SecretStorage } from '@openkaiden/api';
import type { CancellationToken, Configuration, Disposable, Logger, Provider, SecretStorage } from '@openkaiden/api';
import { configuration } from '@openkaiden/api';
import { Container } from 'inversify';
import { assert, beforeEach, describe, expect, test, vi } from 'vitest';

import { PROVIDER_ID } from '/@/claude-extension';
import { ClaudeProviderSymbol, SecretStorageSymbol } from '/@/inject/symbol';

import { ClaudeInferenceManager, type StoredConnection, TOKENS_KEY } from './claude-inference-manager';
Expand Down Expand Up @@ -55,11 +57,20 @@ const SECRET_STORAGE_MOCK: SecretStorage = {
onDidChange: vi.fn(),
};

const CONFIG_UPDATE_MOCK = vi.fn();

const CONFIGURATION_MOCK: Configuration = {
get: vi.fn(),
has: vi.fn(),
update: CONFIG_UPDATE_MOCK,
} as unknown as Configuration;

beforeEach(() => {
vi.resetAllMocks();

vi.mocked(randomUUID).mockReturnValue('fake-uuid-1' as ReturnType<typeof randomUUID>);
vi.mocked(createAnthropic).mockReturnValue(ANTHROPIC_PROVIDER_MOCK);
vi.mocked(configuration.getConfiguration).mockReturnValue(CONFIGURATION_MOCK);

const mockModels: ModelInfo[] = [
{
Expand Down Expand Up @@ -227,7 +238,6 @@ describe('factory', () => {
'claude.factory.apiKey': 'dummyKey',
});

expect(SECRET_STORAGE_MOCK.store).toHaveBeenCalledOnce();
const expected: StoredConnection[] = [{ id: 'fake-uuid-1', token: 'dummyKey' }];
expect(SECRET_STORAGE_MOCK.store).toHaveBeenCalledWith(TOKENS_KEY, JSON.stringify(expected));
});
Expand Down Expand Up @@ -342,21 +352,61 @@ describe('connection delete lifecycle', () => {
mDelete = lifecycle.delete;
});

test('calling delete should remove the connection from storage', async () => {
test('calling delete should remove the connection from storage, clear configuration, and dispose', async () => {
await mDelete();

expect(SECRET_STORAGE_MOCK.store).toHaveBeenCalledTimes(2);
expect(SECRET_STORAGE_MOCK.delete).toHaveBeenCalledWith(`${PROVIDER_ID}:fake-uuid-1:token`);

const saved: StoredConnection[] = [{ id: 'fake-uuid-1', token: 'dummyKey' }];
expect(SECRET_STORAGE_MOCK.store).toHaveBeenNthCalledWith(1, TOKENS_KEY, JSON.stringify(saved));
expect(CONFIG_UPDATE_MOCK).toHaveBeenCalledWith('claude.connection._type', undefined);
expect(CONFIG_UPDATE_MOCK).toHaveBeenCalledWith('claude.connection.token', undefined);

expect(SECRET_STORAGE_MOCK.store).toHaveBeenNthCalledWith(2, TOKENS_KEY, JSON.stringify([]));
expect(disposeMock).toHaveBeenCalledOnce();
});
});

test('calling delete should dispose provider inference connection', async () => {
await mDelete();
describe('workspace configuration', () => {
beforeEach(async () => {
vi.mocked(PROVIDER_MOCK.registerInferenceProviderConnection).mockReturnValue({
dispose: vi.fn(),
});
});

expect(disposeMock).toHaveBeenCalledOnce();
test('should store per-connection secret and set configuration after registration', async () => {
const manager = await createManager();
await manager.init();

const mock = vi.mocked(PROVIDER_MOCK.setInferenceProviderConnectionFactory);
const create = mock.mock.calls[0][0].create;

await create({
'claude.factory.apiKey': 'dummyKey',
});

expect(SECRET_STORAGE_MOCK.store).toHaveBeenCalledWith(`${PROVIDER_ID}:fake-uuid-1:token`, 'dummyKey');

const connection = vi.mocked(PROVIDER_MOCK.registerInferenceProviderConnection).mock.calls[0][0];
expect(configuration.getConfiguration).toHaveBeenCalledWith(undefined, connection);

expect(CONFIG_UPDATE_MOCK).toHaveBeenCalledWith('claude.connection._type', PROVIDER_ID);
expect(CONFIG_UPDATE_MOCK).toHaveBeenCalledWith('claude.connection.token', `${PROVIDER_ID}:fake-uuid-1:token`);
});

test('should set workspace configuration for each restored connection', async () => {
const stored: StoredConnection[] = [
{ id: 'id-1', token: 'key1' },
{ id: 'id-2', token: 'key2' },
];
vi.mocked(SECRET_STORAGE_MOCK.get).mockResolvedValue(JSON.stringify(stored));

const manager = await createManager();
await manager.init();

expect(SECRET_STORAGE_MOCK.store).toHaveBeenCalledWith(`${PROVIDER_ID}:id-1:token`, 'key1');
expect(SECRET_STORAGE_MOCK.store).toHaveBeenCalledWith(`${PROVIDER_ID}:id-2:token`, 'key2');

expect(CONFIG_UPDATE_MOCK).toHaveBeenCalledWith('claude.connection._type', PROVIDER_ID);
expect(CONFIG_UPDATE_MOCK).toHaveBeenCalledWith('claude.connection.token', `${PROVIDER_ID}:id-1:token`);
expect(CONFIG_UPDATE_MOCK).toHaveBeenCalledWith('claude.connection.token', `${PROVIDER_ID}:id-2:token`);
});
});

Expand Down
58 changes: 48 additions & 10 deletions extensions/claude/src/manager/claude-inference-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,18 @@ import { randomUUID } from 'node:crypto';

import { createAnthropic } from '@ai-sdk/anthropic';
import AnthropicClient from '@anthropic-ai/sdk';
import type { Disposable, InferenceModel, Provider, ProviderConnectionStatus, SecretStorage } from '@openkaiden/api';
import type {
Disposable,
InferenceModel,
InferenceProviderConnection,
Provider,
ProviderConnectionStatus,
SecretStorage,
} from '@openkaiden/api';
import { configuration } from '@openkaiden/api';
import { inject, injectable } from 'inversify';

import { PROVIDER_ID } from '/@/claude-extension';
import { ClaudeProviderSymbol, SecretStorageSymbol } from '/@/inject/symbol';

export const TOKENS_KEY = 'claude:tokens';
Expand Down Expand Up @@ -96,6 +105,28 @@ export class ClaudeInferenceManager {
await this.secrets.store(TOKENS_KEY, JSON.stringify(filtered));
}

private getSecretName(connectionId: string): string {
return `${PROVIDER_ID}:${connectionId}:token`;
}

private async setConnectionConfiguration(connection: InferenceProviderConnection, token: string): Promise<void> {
const secretName = this.getSecretName(connection.id);
await this.secrets.store(secretName, token);

const config = configuration.getConfiguration(undefined, connection);
await config.update('claude.connection._type', PROVIDER_ID);
await config.update('claude.connection.token', secretName);
}

private async clearConnectionConfiguration(connection: InferenceProviderConnection): Promise<void> {
const secretName = this.getSecretName(connection.id);
await this.secrets.delete(secretName);

const config = configuration.getConfiguration(undefined, connection);
await config.update('claude.connection._type', undefined);
await config.update('claude.connection.token', undefined);
}

private async registerInferenceProviderConnection({
id,
token,
Expand All @@ -105,19 +136,17 @@ export class ClaudeInferenceManager {
token: string;
baseURL: string;
}): Promise<void> {
if (this.connections.has(id)) {
throw new Error(`connection already exists for id ${id}`);
}

const isCustomBaseURL = baseURL !== DEFAULT_BASE_URL;

const anthropic = createAnthropic({
apiKey: token,
...(isCustomBaseURL && { baseURL }),
});

const clean = async (): Promise<void> => {
this.connections.get(id)?.dispose();
this.connections.delete(id);
await this.removeConnection(id);
};

let status: ProviderConnectionStatus = 'unknown';
let models: InferenceModel[] = [];

Expand All @@ -129,7 +158,7 @@ export class ClaudeInferenceManager {

const connectionName = isCustomBaseURL ? baseURL : this.maskKey(token);

const connectionDisposable = this.claudeProvider.registerInferenceProviderConnection({
const connection: InferenceProviderConnection = {
id,
name: connectionName,
type: isCustomBaseURL ? 'self-hosted' : 'cloud',
Expand All @@ -142,16 +171,25 @@ export class ClaudeInferenceManager {
return status;
},
lifecycle: {
delete: clean.bind(this),
delete: async (): Promise<void> => {
await this.clearConnectionConfiguration(connection);
this.connections.get(id)?.dispose();
this.connections.delete(id);
await this.removeConnection(id);
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
models,
credentials(): Record<string, string> {
return {
[TOKENS_KEY]: token,
};
},
});
};

const connectionDisposable = this.claudeProvider.registerInferenceProviderConnection(connection);
this.connections.set(id, connectionDisposable);

await this.setConnectionConfiguration(connection, token);
}

private async getAnthropicModels(token: string, baseURL: string): Promise<Array<{ label: string }>> {
Expand Down
Loading