Skip to content
Open
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
85 changes: 85 additions & 0 deletions src/__tests__/api-clients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
FileClient,
HooksClient,
isAgentServerVersionError,
LLMMetadataClient,
MCPClient,
ProfilesClient,
SecurityClient,
Expand Down Expand Up @@ -131,6 +132,90 @@ describe('Auxiliary API clients', () => {
);
});

it('LLMMetadataClient.getOpenAISubscriptionModels returns models array', async () => {
global.fetch = jest.fn().mockResolvedValue(
new Response(JSON.stringify({ vendor: 'openai', models: ['gpt-5.2', 'gpt-5.3-codex'] }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
) as typeof fetch;

const client = new LLMMetadataClient({ host: 'http://example.com', apiKey: 'secret' });
const models = await client.getOpenAISubscriptionModels();

expect(models).toEqual(['gpt-5.2', 'gpt-5.3-codex']);
expect(global.fetch).toHaveBeenCalledWith(
'http://example.com/api/llm/subscription/openai/models',
expect.objectContaining({ method: 'GET' })
);
});

it('LLMMetadataClient calls OpenAI subscription endpoints without exposing tokens', async () => {
Comment thread
neubig marked this conversation as resolved.
const responses = [
{ vendor: 'openai', connected: false, account_email: null, expires_at: null },
{
device_code: 'opaque-token',
user_code: 'ABCD-EFGH',
verification_uri: 'https://auth.example/device',
verification_uri_complete: null,
expires_at: 4102444800000,
interval_seconds: 5,
},
{ vendor: 'openai', connected: true, account_email: null, expires_at: 4102444800000 },
{ vendor: 'openai', connected: false, account_email: null, expires_at: null },
];
global.fetch = jest.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify(responses.shift()), {
status: 200,
headers: { 'content-type': 'application/json' },
})
)
) as typeof fetch;

const client = new LLMMetadataClient({ host: 'http://example.com', apiKey: 'secret' });

await expect(client.getOpenAISubscriptionStatus()).resolves.toMatchObject({
vendor: 'openai',
connected: false,
});
await expect(client.startOpenAISubscriptionDeviceLogin()).resolves.toMatchObject({
device_code: 'opaque-token',
user_code: 'ABCD-EFGH',
});
await expect(client.pollOpenAISubscriptionDeviceLogin('opaque-token')).resolves.toMatchObject({
connected: true,
expires_at: 4102444800000,
});
await expect(client.logoutOpenAISubscription()).resolves.toMatchObject({ connected: false });

expect(global.fetch).toHaveBeenNthCalledWith(
1,
'http://example.com/api/llm/subscription/openai/status',
expect.objectContaining({ method: 'GET' })
);
expect(global.fetch).toHaveBeenNthCalledWith(
2,
'http://example.com/api/llm/subscription/openai/device/start',
expect.objectContaining({ method: 'POST' })
);
expect(global.fetch).toHaveBeenNthCalledWith(
3,
'http://example.com/api/llm/subscription/openai/device/poll',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ device_code: 'opaque-token' }),
})
);
expect(global.fetch).toHaveBeenNthCalledWith(
4,
'http://example.com/api/llm/subscription/openai/logout',
expect.objectContaining({ method: 'POST' })
);
expect(JSON.stringify((global.fetch as jest.Mock).mock.calls)).not.toContain('access-token');
expect(JSON.stringify((global.fetch as jest.Mock).mock.calls)).not.toContain('refresh-token');
});

it('SkillsClient.syncSkills posts to the sync endpoint', async () => {
global.fetch = jest.fn().mockResolvedValue(
new Response(JSON.stringify({ status: 'success', message: 'ok' }), {
Expand Down
49 changes: 48 additions & 1 deletion src/client/llm-client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { HttpClient } from './http-client';
import { ModelsResponse, ProvidersResponse, VerifiedModelsResponse } from '../models/api';
import {
LLMSubscriptionDevicePollRequest,
LLMSubscriptionDeviceStartResponse,
LLMSubscriptionModelsResponse,
LLMSubscriptionStatusResponse,
ModelsResponse,
ProvidersResponse,
VerifiedModelsResponse,
} from '../models/api';

export interface LLMMetadataClientOptions {
host: string;
Expand Down Expand Up @@ -39,6 +47,45 @@ export class LLMMetadataClient {
return response.data.models;
}

async getOpenAISubscriptionModels(): Promise<string[]> {
const response = await this.client.get<LLMSubscriptionModelsResponse>(
'/api/llm/subscription/openai/models'
);
return response.data.models;
}

async getOpenAISubscriptionStatus(): Promise<LLMSubscriptionStatusResponse> {
const response = await this.client.get<LLMSubscriptionStatusResponse>(
'/api/llm/subscription/openai/status'
);
return response.data;
}

async startOpenAISubscriptionDeviceLogin(): Promise<LLMSubscriptionDeviceStartResponse> {
const response = await this.client.post<LLMSubscriptionDeviceStartResponse>(
'/api/llm/subscription/openai/device/start'
);
return response.data;
}

async pollOpenAISubscriptionDeviceLogin(
deviceCode: string
): Promise<LLMSubscriptionStatusResponse> {
const body: LLMSubscriptionDevicePollRequest = { device_code: deviceCode };
const response = await this.client.post<LLMSubscriptionStatusResponse>(
'/api/llm/subscription/openai/device/poll',
body
);
return response.data;
}

async logoutOpenAISubscription(): Promise<LLMSubscriptionStatusResponse> {
const response = await this.client.post<LLMSubscriptionStatusResponse>(
'/api/llm/subscription/openai/logout'
);
return response.data;
}

close(): void {
this.client.close();
}
Expand Down
25 changes: 25 additions & 0 deletions src/models/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,31 @@ export interface VerifiedModelsResponse {
models: Record<string, string[]>;
}

export interface LLMSubscriptionStatusResponse {
vendor: string;
connected: boolean;
account_email: string | null;
expires_at: number | null;
}
Comment thread
neubig marked this conversation as resolved.

export interface LLMSubscriptionDeviceStartResponse {
device_code: string;
user_code: string;
verification_uri: string;
verification_uri_complete: string | null;
Comment thread
neubig marked this conversation as resolved.
Comment thread
neubig marked this conversation as resolved.
expires_at: number;
interval_seconds: number;
}
Comment thread
neubig marked this conversation as resolved.

export interface LLMSubscriptionDevicePollRequest {
device_code: string;
}

export interface LLMSubscriptionModelsResponse {
vendor: string;
models: string[];
}

export interface SettingsSchema {
model_name: string;
sections: Array<Record<string, unknown>>;
Expand Down
Loading