From c06163a030074cd2e2aa7acfe359cafd216457a9 Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 23 May 2026 15:35:21 +0000 Subject: [PATCH 1/2] Add LLM subscription client methods Co-authored-by: openhands --- src/__tests__/api-clients.test.ts | 67 +++++++++++++++++++++++++++++++ src/client/llm-client.ts | 49 +++++++++++++++++++++- src/models/api.ts | 25 ++++++++++++ 3 files changed, 140 insertions(+), 1 deletion(-) diff --git a/src/__tests__/api-clients.test.ts b/src/__tests__/api-clients.test.ts index 2390736..4feca0b 100644 --- a/src/__tests__/api-clients.test.ts +++ b/src/__tests__/api-clients.test.ts @@ -20,6 +20,7 @@ import { FileClient, HooksClient, isAgentServerVersionError, + LLMMetadataClient, MCPClient, ProfilesClient, SecurityClient, @@ -131,6 +132,72 @@ describe('Auxiliary API clients', () => { ); }); + 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' }), { diff --git a/src/client/llm-client.ts b/src/client/llm-client.ts index 5b365c3..5fc93ba 100644 --- a/src/client/llm-client.ts +++ b/src/client/llm-client.ts @@ -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; @@ -39,6 +47,45 @@ export class LLMMetadataClient { return response.data.models; } + async getOpenAISubscriptionModels(): Promise { + const response = await this.client.get( + '/api/llm/subscription/openai/models' + ); + return response.data.models; + } + + async getOpenAISubscriptionStatus(): Promise { + const response = await this.client.get( + '/api/llm/subscription/openai/status' + ); + return response.data; + } + + async startOpenAISubscriptionDeviceLogin(): Promise { + const response = await this.client.post( + '/api/llm/subscription/openai/device/start' + ); + return response.data; + } + + async pollOpenAISubscriptionDeviceLogin( + deviceCode: string + ): Promise { + const body: LLMSubscriptionDevicePollRequest = { device_code: deviceCode }; + const response = await this.client.post( + '/api/llm/subscription/openai/device/poll', + body + ); + return response.data; + } + + async logoutOpenAISubscription(): Promise { + const response = await this.client.post( + '/api/llm/subscription/openai/logout' + ); + return response.data; + } + close(): void { this.client.close(); } diff --git a/src/models/api.ts b/src/models/api.ts index 08f0d60..27bf519 100644 --- a/src/models/api.ts +++ b/src/models/api.ts @@ -30,6 +30,31 @@ export interface VerifiedModelsResponse { models: Record; } +export interface LLMSubscriptionStatusResponse { + vendor: string; + connected: boolean; + account_email?: string | null; + expires_at?: number | string | null; +} + +export interface LLMSubscriptionDeviceStartResponse { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete?: string | null; + expires_at: number | string; + interval_seconds: number; +} + +export interface LLMSubscriptionDevicePollRequest { + device_code: string; +} + +export interface LLMSubscriptionModelsResponse { + vendor: string; + models: string[]; +} + export interface SettingsSchema { model_name: string; sections: Array>; From dd0383cdbf170de65faa1c3038e3cc97256ea07b Mon Sep 17 00:00:00 2001 From: Graham Neubig Date: Mon, 25 May 2026 12:45:10 -0400 Subject: [PATCH 2/2] Address subscription client review feedback --- src/__tests__/api-clients.test.ts | 18 ++++++++++++++++++ src/models/api.ts | 8 ++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/__tests__/api-clients.test.ts b/src/__tests__/api-clients.test.ts index 4feca0b..7772eaa 100644 --- a/src/__tests__/api-clients.test.ts +++ b/src/__tests__/api-clients.test.ts @@ -132,6 +132,24 @@ 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 () => { const responses = [ { vendor: 'openai', connected: false, account_email: null, expires_at: null }, diff --git a/src/models/api.ts b/src/models/api.ts index 27bf519..31a5cb4 100644 --- a/src/models/api.ts +++ b/src/models/api.ts @@ -33,16 +33,16 @@ export interface VerifiedModelsResponse { export interface LLMSubscriptionStatusResponse { vendor: string; connected: boolean; - account_email?: string | null; - expires_at?: number | string | null; + account_email: string | null; + expires_at: number | null; } export interface LLMSubscriptionDeviceStartResponse { device_code: string; user_code: string; verification_uri: string; - verification_uri_complete?: string | null; - expires_at: number | string; + verification_uri_complete: string | null; + expires_at: number; interval_seconds: number; }