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
67 changes: 67 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,72 @@ describe('Auxiliary API clients', () => {
);
});

it('LLMMetadataClient calls OpenAI subscription endpoints without exposing tokens', async () => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Important: Missing test coverage for getOpenAISubscriptionModels().

The implementation adds 5 methods but this test only exercises 4. Add a test case for getOpenAISubscriptionModels() to verify it correctly calls GET /api/llm/subscription/openai/models and returns the models array.

Suggested change
it('LLMMetadataClient calls OpenAI subscription endpoints without exposing tokens', async () => {
it('LLMMetadataClient.getOpenAISubscriptionModels() returns models array', async () => {
global.fetch = jest.fn().mockResolvedValue(
new Response(JSON.stringify({ vendor: 'openai', models: ['gpt-4', 'gpt-3.5-turbo'] }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
);
const client = new LLMMetadataClient({ host: 'http://example.com', apiKey: 'secret' });
const models = await client.getOpenAISubscriptionModels();
expect(models).toEqual(['gpt-4', 'gpt-3.5-turbo']);
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 () => {

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 | string | null;
}
Comment on lines +33 to +38
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Type inconsistencies in optional fields.

  1. expires_at: number | string | null - Mixing number and string types creates ambiguity. Pick one:

    • If it's always a timestamp milliseconds: expires_at?: number | null
    • If it's always an ISO date string: expires_at?: string | null
  2. Optional + nullable redundancy - Using both ?: and | null is redundant:

    • account_email?: string | null means "optional, and when present can be null or string"
    • Better: account_email: string | null (always present but nullable) OR account_email?: string (optional but never explicitly null)

Check what the agent-server actually returns and match that exactly.


export interface LLMSubscriptionDeviceStartResponse {
device_code: string;
user_code: string;
verification_uri: string;
verification_uri_complete?: string | null;
expires_at: number | string;
interval_seconds: number;
}
Comment on lines +40 to +47
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Same type inconsistency as above.

  • verification_uri_complete?: string | null - Choose one: optional OR nullable, not both
  • expires_at: number | string - Pick a single type (number for timestamp milliseconds, or string for ISO date)

Consistency across these subscription response types will prevent runtime type confusion.


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