From c06163a030074cd2e2aa7acfe359cafd216457a9 Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 23 May 2026 15:35:21 +0000 Subject: [PATCH] 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>;