diff --git a/src/__tests__/providers-client.test.ts b/src/__tests__/providers-client.test.ts new file mode 100644 index 0000000..1d2b779 --- /dev/null +++ b/src/__tests__/providers-client.test.ts @@ -0,0 +1,270 @@ +/** + * Tests for the Model Provider client methods on LLMMetadataClient. + * + * Verifies each /api/llm/model-providers endpoint is hit with the right method, + * path, and body, and that responses are unpacked correctly. The key is never + * echoed by the server (api_key_set only), so no key handling is asserted here. + */ + +import { LLMMetadataClient } from '../client/llm-client'; +import type { ModelProvider, TestProviderResponse } from '../models/api'; + +const originalFetch = global.fetch; + +function mockFetch(responseBody: unknown, status = 200): typeof fetch { + return jest.fn().mockResolvedValue( + new Response(JSON.stringify(responseBody), { + status, + headers: { 'content-type': 'application/json' }, + }) + ) as typeof fetch; +} + +function captureFetch(): { + fetch: typeof fetch; + calls: { url: string; init?: RequestInit }[]; +} { + const calls: { url: string; init?: RequestInit }[] = []; + const fn = jest.fn().mockImplementation((url: string, init?: RequestInit) => { + calls.push({ url, init }); + return Promise.resolve( + new Response(JSON.stringify({}), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ); + }); + return { fetch: fn as unknown as typeof fetch, calls }; +} + +describe('LLMMetadataClient model providers', () => { + let client: LLMMetadataClient; + + beforeEach(() => { + client = new LLMMetadataClient({ + host: 'http://example.com', + apiKey: 'secret', + }); + }); + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it('listProviders GETs /api/llm/model-providers and returns the array', async () => { + const providers: ModelProvider[] = [ + { + id: 'abc', + display_name: 'OpenAI', + kind: 'openai', + base_url: 'https://api.openai.com/v1', + wire_api: 'chat', + custom_headers: { 'X-Org': 'eng' }, + models: [{ name: 'gpt-5.6-luna', wire_api: null }], + created_at: 1700000000, + updated_at: 1700000100, + api_key_set: true, + }, + ]; + global.fetch = mockFetch(providers); + + const result = await client.listProviders(); + expect(result).toEqual(providers); + expect(global.fetch).toHaveBeenCalledWith( + 'http://example.com/api/llm/model-providers', + expect.objectContaining({ method: 'GET' }) + ); + }); + + it('createProvider POSTs body to /api/llm/model-providers', async () => { + const created: ModelProvider = { + id: 'abc', + display_name: 'OpenAI', + kind: 'openai', + base_url: 'https://api.openai.com/v1', + wire_api: 'responses', + custom_headers: { 'X-Org': 'eng' }, + models: [], + created_at: 1700000000, + updated_at: 1700000000, + api_key_set: true, + }; + global.fetch = mockFetch(created, 201); + + const result = await client.createProvider({ + display_name: 'OpenAI', + kind: 'openai', + key: 'sk-test', + base_url: 'https://api.openai.com/v1', + wire_api: 'responses', + custom_headers: { 'X-Org': 'eng' }, + }); + expect(result).toEqual(created); + expect(global.fetch).toHaveBeenCalledWith( + 'http://example.com/api/llm/model-providers', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + display_name: 'OpenAI', + kind: 'openai', + key: 'sk-test', + base_url: 'https://api.openai.com/v1', + wire_api: 'responses', + custom_headers: { 'X-Org': 'eng' }, + }), + }) + ); + }); + + it('getProvider GETs /api/llm/model-providers/{id}', async () => { + const provider: ModelProvider = { + id: 'abc', + display_name: 'OpenAI', + kind: 'openai', + wire_api: 'auto', + custom_headers: {}, + models: [], + created_at: 1, + updated_at: 1, + api_key_set: true, + }; + global.fetch = mockFetch(provider); + + await client.getProvider('abc'); + expect(global.fetch).toHaveBeenCalledWith( + 'http://example.com/api/llm/model-providers/abc', + expect.objectContaining({ method: 'GET' }) + ); + }); + + it('updateProvider PATCHes {id} with the partial body', async () => { + const { fetch, calls } = captureFetch(); + global.fetch = fetch; + + await client.updateProvider('abc', { + display_name: 'Work OpenAI', + base_url: 'https://proxy.example/v1', + wire_api: 'chat', + custom_headers: { 'X-Org': 'eng' }, + }); + expect(calls[0].url).toBe('http://example.com/api/llm/model-providers/abc'); + expect(calls[0].init?.method).toBe('PATCH'); + expect(calls[0].init?.body).toBe( + JSON.stringify({ + display_name: 'Work OpenAI', + base_url: 'https://proxy.example/v1', + wire_api: 'chat', + custom_headers: { 'X-Org': 'eng' }, + }) + ); + }); + + it('updateProvider can rotate the key', async () => { + const { fetch, calls } = captureFetch(); + global.fetch = fetch; + + await client.updateProvider('abc', { key: 'sk-new' }); + expect(calls[0].init?.method).toBe('PATCH'); + expect(calls[0].init?.body).toBe(JSON.stringify({ key: 'sk-new' })); + }); + + it('deleteProvider DELETEs {id} and returns the removed provider', async () => { + const removed: ModelProvider = { + id: 'abc', + display_name: 'OpenAI', + kind: 'openai', + wire_api: 'auto', + custom_headers: {}, + models: [], + created_at: 1, + updated_at: 1, + api_key_set: false, + }; + global.fetch = mockFetch(removed); + + const result = await client.deleteProvider('abc'); + expect(result).toEqual(removed); + expect(global.fetch).toHaveBeenCalledWith( + 'http://example.com/api/llm/model-providers/abc', + expect.objectContaining({ method: 'DELETE' }) + ); + }); + + it('addProviderModel POSTs the model body to {id}/models', async () => { + const { fetch, calls } = captureFetch(); + global.fetch = fetch; + + await client.addProviderModel('abc', { name: 'gpt-5.6-sol' }); + expect(calls[0].url).toBe('http://example.com/api/llm/model-providers/abc/models'); + expect(calls[0].init?.method).toBe('POST'); + expect(calls[0].init?.body).toBe(JSON.stringify({ name: 'gpt-5.6-sol' })); + }); + + it('updateProviderModel PATCHes {id}/models/{name} with the payload', async () => { + const { fetch, calls } = captureFetch(); + global.fetch = fetch; + + await client.updateProviderModel('abc', 'gpt-5.6-sol', { + name: 'gpt-5.6-terra', + wire_api: 'responses', + }); + expect(calls[0].url).toBe( + 'http://example.com/api/llm/model-providers/abc/models/gpt-5.6-sol' + ); + expect(calls[0].init?.method).toBe('PATCH'); + expect(calls[0].init?.body).toBe( + JSON.stringify({ name: 'gpt-5.6-terra', wire_api: 'responses' }) + ); + }); + + it('removeProviderModel DELETEs {id}/models/{name}', async () => { + const { fetch, calls } = captureFetch(); + global.fetch = fetch; + + await client.removeProviderModel('abc', 'gpt-5.6-sol'); + expect(calls[0].url).toBe( + 'http://example.com/api/llm/model-providers/abc/models/gpt-5.6-sol' + ); + expect(calls[0].init?.method).toBe('DELETE'); + }); + + it('testProvider POSTs to {id}/test and returns the probe result', async () => { + const probe: TestProviderResponse = { + id: 'abc', + ok: true, + verified: false, + suggested_models: ['gpt-5.6-luna', 'gpt-5.6-sol'], + error: null, + }; + global.fetch = mockFetch(probe); + + const result = await client.testProvider('abc'); + expect(result).toEqual(probe); + expect(result.verified).toBe(false); + expect(global.fetch).toHaveBeenCalledWith( + 'http://example.com/api/llm/model-providers/abc/test', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('surfaces a typed HttpError with the server body on a non-2xx response', async () => { + global.fetch = mockFetch({ detail: 'provider unknown' }, 400); + + await expect(client.listProviders()).rejects.toMatchObject({ + name: 'HttpError', + status: 400, + response: { detail: 'provider unknown' }, + }); + }); + + it('encodes the provider id and model name in the path', async () => { + const { fetch, calls } = captureFetch(); + global.fetch = fetch; + + await client.removeProviderModel('a/b c', 'x/y z'); + expect(calls[0].url).toBe( + 'http://example.com/api/llm/model-providers/a%2Fb%20c/models/x%2Fy%20z' + ); + }); +}); diff --git a/src/client/llm-client.ts b/src/client/llm-client.ts index 5fc93ba..46d9cb6 100644 --- a/src/client/llm-client.ts +++ b/src/client/llm-client.ts @@ -1,11 +1,16 @@ import { HttpClient } from './http-client'; import { + CreateProviderRequest, LLMSubscriptionDevicePollRequest, LLMSubscriptionDeviceStartResponse, LLMSubscriptionModelsResponse, LLMSubscriptionStatusResponse, + ModelProvider, ModelsResponse, + ProviderModelPayload, ProvidersResponse, + TestProviderResponse, + UpdateProviderRequest, VerifiedModelsResponse, } from '../models/api'; @@ -86,6 +91,100 @@ export class LLMMetadataClient { return response.data; } + // ── Model Providers (/api/llm/model-providers) ──────────────────────── + // + // Connect a provider once with one key, then manage its models under it + // (add / edit / remove). The key is held on the provider as a named secret + // server-side and never returned (only `api_key_set`; `secret_name` is never + // exposed). See software-agent-sdk#4455. + + async listProviders(): Promise { + const response = await this.client.get('/api/llm/model-providers'); + return response.data; + } + + async createProvider(body: CreateProviderRequest): Promise { + const response = await this.client.post('/api/llm/model-providers', body); + return response.data; + } + + async getProvider(providerId: string): Promise { + const response = await this.client.get( + `/api/llm/model-providers/${encodeURIComponent(providerId)}` + ); + return response.data; + } + + /** Update provider fields or rotate its key. Provide at least one field. */ + async updateProvider( + providerId: string, + body: UpdateProviderRequest + ): Promise { + const response = await this.client.patch( + `/api/llm/model-providers/${encodeURIComponent(providerId)}`, + body + ); + return response.data; + } + + /** Remove a provider and its named secret. Returns the removed provider. */ + async deleteProvider(providerId: string): Promise { + const response = await this.client.delete( + `/api/llm/model-providers/${encodeURIComponent(providerId)}` + ); + return response.data; + } + + /** Add a model under the provider. Returns the updated provider. */ + async addProviderModel( + providerId: string, + body: ProviderModelPayload + ): Promise { + const response = await this.client.post( + `/api/llm/model-providers/${encodeURIComponent(providerId)}/models`, + body + ); + return response.data; + } + + /** Rename a model and/or change its per-model wire-API override. */ + async updateProviderModel( + providerId: string, + modelName: string, + body: ProviderModelPayload + ): Promise { + const response = await this.client.patch( + `/api/llm/model-providers/${encodeURIComponent(providerId)}/models/` + + `${encodeURIComponent(modelName)}`, + body + ); + return response.data; + } + + /** Remove a model from the provider. Returns the updated provider. */ + async removeProviderModel( + providerId: string, + modelName: string + ): Promise { + const response = await this.client.delete( + `/api/llm/model-providers/${encodeURIComponent(providerId)}/models/` + + `${encodeURIComponent(modelName)}` + ); + return response.data; + } + + /** + * Probe the provider's stored key. `verified` reflects whether a real network + * check happened; `suggested_models` is a catalog convenience for the "add + * model" affordance and never mutates the curated model list. + */ + async testProvider(providerId: string): Promise { + const response = await this.client.post( + `/api/llm/model-providers/${encodeURIComponent(providerId)}/test` + ); + return response.data; + } + close(): void { this.client.close(); } diff --git a/src/index.ts b/src/index.ts index 0e89ec5..5a4754a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -416,6 +416,13 @@ export type { MCPOAuthCallbackRequest, SharedConversation, EventPage as ApiEventPage, + WireApi, + ProviderModel, + ModelProvider, + CreateProviderRequest, + UpdateProviderRequest, + ProviderModelPayload, + TestProviderResponse, } from './models/api'; export type { WebSocketClientOptions } from './events/websocket-client'; diff --git a/src/models/api.ts b/src/models/api.ts index 0373988..7492abe 100644 --- a/src/models/api.ts +++ b/src/models/api.ts @@ -55,6 +55,87 @@ export interface LLMSubscriptionModelsResponse { models: string[]; } +// ── Model Providers (OpenHands/OpenHands#15492) ───────────────────────── +// +// A model provider is the persisted record for "connect a provider once, then +// manage its models under it". One key is held on the provider and shared by +// every nested model. The key is stored as a named secret server-side; these +// responses never echo it (only `api_key_set`), and never expose the internal +// `secret_name`. Mirrors the agent-server contract at +// `/api/llm/model-providers` (software-agent-sdk#4455). + +/** Wire format a provider/model endpoint speaks. */ +export type WireApi = 'auto' | 'chat' | 'responses'; + +/** A model nested under a provider. Inherits the provider's key/endpoint. */ +export interface ProviderModel { + name: string; + /** Optional per-model override of the provider's `wire_api`. */ + wire_api?: WireApi | null; +} + +/** Masked provider view — never includes the raw key or `secret_name`. */ +export interface ModelProvider { + id: string; + display_name: string; + /** Preset id or litellm provider key, e.g. 'openai', 'anthropic', 'custom'. */ + kind: string; + base_url?: string | null; + wire_api: WireApi; + custom_headers: Record; + models: ProviderModel[]; + created_at: number; + updated_at: number; + /** True when a key is stored; the key itself is never returned. */ + api_key_set: boolean; +} + +export interface CreateProviderRequest { + display_name: string; + kind?: string; + /** Written to the SecretsStore; never echoed back. */ + key: string; + base_url?: string | null; + wire_api?: WireApi; + custom_headers?: Record; + /** Optional models to seed the provider with. */ + models?: ProviderModel[]; +} + +/** Partial update. Provide at least one field. `key` rotates the named secret. */ +export interface UpdateProviderRequest { + display_name?: string; + kind?: string; + key?: string; + base_url?: string | null; + wire_api?: WireApi; + custom_headers?: Record; +} + +/** Payload to add or edit a nested model. */ +export interface ProviderModelPayload { + name: string; + wire_api?: WireApi | null; +} + +/** + * Result of probing a provider's stored key. Never mutates the curated model + * list — `suggested_models` is the provider's advertised catalog, offered only + * as a convenience for the "add model" affordance. + */ +export interface TestProviderResponse { + id: string; + ok: boolean; + /** + * True only when a live network probe confirmed the provider accepted the + * key. When false, `suggested_models` is a catalog rather than a proven + * grant — clients must not present the key as authenticated. + */ + verified: boolean; + suggested_models: string[]; + error?: string | null; +} + export interface SettingsSchema { model_name: string; sections: Array>;