From a015c9f2494bdfb1c0615d9f247c6645f62d2e39 Mon Sep 17 00:00:00 2001 From: Juan Pedro Michelini Jorge Date: Mon, 10 Aug 2026 18:25:53 -0300 Subject: [PATCH 1/5] scaffold: provider-connections (draft) --- src/llm/connections.ts | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/llm/connections.ts diff --git a/src/llm/connections.ts b/src/llm/connections.ts new file mode 100644 index 0000000..74c1d8b --- /dev/null +++ b/src/llm/connections.ts @@ -0,0 +1,36 @@ +/** + * Provider Connection client (scaffold, draft). + * + * Generated/typed client methods for the new /api/llm/connections endpoints + * added in software-agent-sdk. This package MUST be released to npm before + * the OpenHands frontend can consume the new endpoints (release gate). + * + * Tracking: OpenHands/OpenHands#15492, Linear OSS-5295. + * Scope: typescript-client PR2 of the provider-connections plan. + * + * TODO (implementation): + * - Add Connection / ConnectionCreate / ConnectionValidate types. + * - listConnections / createConnection / getConnection / patchConnection / + * deleteConnection / validateConnection methods. + * - Regenerate src/generated/* from the updated agent-server OpenAPI. + * - Bump version + release to npm. + */ + +export interface ProviderConnection { + id: string; + provider: string; + label?: string; + maskedKey: string; + modelCount: number; + lastRefreshedAt?: string; +} + +export interface CreateConnectionRequest { + provider: string; + key: string; + label?: string; +} + +export const connectionService = { + // TODO: implement against /api/llm/connections +} as const; From c998f74a23e9798a43332fc9218838aea4c8456c Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 10 Aug 2026 21:55:29 +0000 Subject: [PATCH 2/5] feat: Provider Connection client methods + types Add typed client methods for the new /api/llm/connections endpoints from software-agent-sdk. Connect a vendor once with one key, pick from its model catalog; the key is stored as a named secret server-side and never returned (api_key_set only). - models/api: ProviderConnection, CreateConnectionRequest, UpdateConnectionRequest, ValidateConnectionResponse types. - LLMMetadataClient: listConnections / createConnection / getConnection / updateConnection (rotate key, rename, set models) / deleteConnection / validateConnection. Connection ids are URL-encoded in the path. - Re-export the new types from the package index. - Remove the stub src/llm/connections.ts (replaced by the real client methods). Tests: 8 new tests covering each endpoint (method, path, body unpacking, key rotation, id encoding). Full suite green: 311 passed. Lint clean (0 errors), build succeeds. Release gate: bump @openhands/typescript-client version and publish to npm before the OpenHands frontend consumes these endpoints. Refs OpenHands/OpenHands#15492, Linear OSS-5295. Co-authored-by: openhands --- src/__tests__/connections-client.test.ts | 179 +++++++++++++++++++++++ src/client/llm-client.ts | 59 ++++++++ src/index.ts | 4 + src/llm/connections.ts | 36 ----- src/models/api.ts | 38 +++++ 5 files changed, 280 insertions(+), 36 deletions(-) create mode 100644 src/__tests__/connections-client.test.ts delete mode 100644 src/llm/connections.ts diff --git a/src/__tests__/connections-client.test.ts b/src/__tests__/connections-client.test.ts new file mode 100644 index 0000000..edf236c --- /dev/null +++ b/src/__tests__/connections-client.test.ts @@ -0,0 +1,179 @@ +/** + * Tests for the Provider Connection client methods on LLMMetadataClient. + * + * Verifies each /api/llm/connections 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 { + ProviderConnection, + ValidateConnectionResponse, +} 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 connections', () => { + let client: LLMMetadataClient; + + beforeEach(() => { + client = new LLMMetadataClient({ + host: 'http://example.com', + apiKey: 'secret', + }); + }); + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it('listConnections GETs /api/llm/connections and returns the array', async () => { + const conns: ProviderConnection[] = [ + { + id: 'abc', + provider: 'openai', + label: 'work', + models: ['gpt-4o'], + created_at: 1700000000, + last_validated_at: 1700000100, + api_key_set: true, + }, + ]; + global.fetch = mockFetch(conns); + + const result = await client.listConnections(); + expect(result).toEqual(conns); + expect(global.fetch).toHaveBeenCalledWith( + 'http://example.com/api/llm/connections', + expect.objectContaining({ method: 'GET' }) + ); + }); + + it('createConnection POSTs body to /api/llm/connections', async () => { + const created: ProviderConnection = { + id: 'abc', + provider: 'openai', + models: [], + created_at: 1700000000, + api_key_set: true, + }; + global.fetch = mockFetch(created, 201); + + const result = await client.createConnection({ + provider: 'openai', + key: 'sk-test', + }); + expect(result).toEqual(created); + expect(global.fetch).toHaveBeenCalledWith( + 'http://example.com/api/llm/connections', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ provider: 'openai', key: 'sk-test' }), + }) + ); + }); + + it('getConnection GETs /api/llm/connections/{id}', async () => { + const conn: ProviderConnection = { + id: 'abc', + provider: 'openai', + models: [], + created_at: 1, + api_key_set: true, + }; + global.fetch = mockFetch(conn); + + await client.getConnection('abc'); + expect(global.fetch).toHaveBeenCalledWith( + 'http://example.com/api/llm/connections/abc', + expect.objectContaining({ method: 'GET' }) + ); + }); + + it('updateConnection PATCHes {id} with the partial body', async () => { + const { fetch, calls } = captureFetch(); + global.fetch = fetch; + + await client.updateConnection('abc', { label: 'work', models: ['gpt-4o'] }); + expect(calls[0].url).toBe('http://example.com/api/llm/connections/abc'); + expect(calls[0].init?.method).toBe('PATCH'); + expect(calls[0].init?.body).toBe( + JSON.stringify({ label: 'work', models: ['gpt-4o'] }) + ); + }); + + it('updateConnection can rotate the key', async () => { + const { fetch, calls } = captureFetch(); + global.fetch = fetch; + + await client.updateConnection('abc', { key: 'sk-new' }); + expect(calls[0].init?.method).toBe('PATCH'); + expect(calls[0].init?.body).toBe(JSON.stringify({ key: 'sk-new' })); + }); + + it('deleteConnection DELETEs {id} and resolves', async () => { + const { fetch, calls } = captureFetch(); + global.fetch = fetch; + + await expect(client.deleteConnection('abc')).resolves.toBeUndefined(); + expect(calls[0].url).toBe('http://example.com/api/llm/connections/abc'); + expect(calls[0].init?.method).toBe('DELETE'); + }); + + it('validateConnection POSTs to {id}/validate and returns the response', async () => { + const validate: ValidateConnectionResponse = { + id: 'abc', + provider: 'openai', + ok: true, + models: ['gpt-4o', 'gpt-4o-mini'], + error: null, + validated_at: 1700000200, + }; + global.fetch = mockFetch(validate); + + const result = await client.validateConnection('abc'); + expect(result).toEqual(validate); + expect(global.fetch).toHaveBeenCalledWith( + 'http://example.com/api/llm/connections/abc/validate', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('encodes the connection id in the path', async () => { + const { fetch, calls } = captureFetch(); + global.fetch = fetch; + + await client.getConnection('a/b c'); + expect(calls[0].url).toBe( + 'http://example.com/api/llm/connections/a%2Fb%20c' + ); + }); +}); diff --git a/src/client/llm-client.ts b/src/client/llm-client.ts index 5fc93ba..d6a66e0 100644 --- a/src/client/llm-client.ts +++ b/src/client/llm-client.ts @@ -1,11 +1,15 @@ import { HttpClient } from './http-client'; import { + CreateConnectionRequest, LLMSubscriptionDevicePollRequest, LLMSubscriptionDeviceStartResponse, LLMSubscriptionModelsResponse, LLMSubscriptionStatusResponse, ModelsResponse, + ProviderConnection, ProvidersResponse, + UpdateConnectionRequest, + ValidateConnectionResponse, VerifiedModelsResponse, } from '../models/api'; @@ -86,6 +90,61 @@ export class LLMMetadataClient { return response.data; } + // ── Provider Connections (/api/llm/connections) ─────────────────────── + // + // Connect a vendor once with one key, pick from its model catalog. The key is + // stored as a named secret server-side and never returned (api_key_set only). + + async listConnections(): Promise { + const response = await this.client.get( + '/api/llm/connections' + ); + return response.data; + } + + async createConnection( + body: CreateConnectionRequest + ): Promise { + const response = await this.client.post( + '/api/llm/connections', + body + ); + return response.data; + } + + async getConnection(connectionId: string): Promise { + const response = await this.client.get( + `/api/llm/connections/${encodeURIComponent(connectionId)}` + ); + return response.data; + } + + async updateConnection( + connectionId: string, + body: UpdateConnectionRequest + ): Promise { + const response = await this.client.patch( + `/api/llm/connections/${encodeURIComponent(connectionId)}`, + body + ); + return response.data; + } + + async deleteConnection(connectionId: string): Promise { + await this.client.delete( + `/api/llm/connections/${encodeURIComponent(connectionId)}` + ); + } + + async validateConnection( + connectionId: string + ): Promise { + const response = await this.client.post( + `/api/llm/connections/${encodeURIComponent(connectionId)}/validate` + ); + return response.data; + } + close(): void { this.client.close(); } diff --git a/src/index.ts b/src/index.ts index 0e89ec5..cc2a460 100644 --- a/src/index.ts +++ b/src/index.ts @@ -416,6 +416,10 @@ export type { MCPOAuthCallbackRequest, SharedConversation, EventPage as ApiEventPage, + ProviderConnection, + CreateConnectionRequest, + UpdateConnectionRequest, + ValidateConnectionResponse, } from './models/api'; export type { WebSocketClientOptions } from './events/websocket-client'; diff --git a/src/llm/connections.ts b/src/llm/connections.ts deleted file mode 100644 index 74c1d8b..0000000 --- a/src/llm/connections.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Provider Connection client (scaffold, draft). - * - * Generated/typed client methods for the new /api/llm/connections endpoints - * added in software-agent-sdk. This package MUST be released to npm before - * the OpenHands frontend can consume the new endpoints (release gate). - * - * Tracking: OpenHands/OpenHands#15492, Linear OSS-5295. - * Scope: typescript-client PR2 of the provider-connections plan. - * - * TODO (implementation): - * - Add Connection / ConnectionCreate / ConnectionValidate types. - * - listConnections / createConnection / getConnection / patchConnection / - * deleteConnection / validateConnection methods. - * - Regenerate src/generated/* from the updated agent-server OpenAPI. - * - Bump version + release to npm. - */ - -export interface ProviderConnection { - id: string; - provider: string; - label?: string; - maskedKey: string; - modelCount: number; - lastRefreshedAt?: string; -} - -export interface CreateConnectionRequest { - provider: string; - key: string; - label?: string; -} - -export const connectionService = { - // TODO: implement against /api/llm/connections -} as const; diff --git a/src/models/api.ts b/src/models/api.ts index 0373988..3efcf58 100644 --- a/src/models/api.ts +++ b/src/models/api.ts @@ -55,6 +55,44 @@ export interface LLMSubscriptionModelsResponse { models: string[]; } +// ── Provider Connections (OpenHands/OpenHands#15492) ──────────────────── +// +// A Provider Connection is the persisted record for "connect a vendor once +// with one key, pick from its model catalog". The key is stored as a named +// secret server-side; these responses never echo it (api_key_set only). + +export interface ProviderConnection { + id: string; + provider: string; + label?: string; + models: string[]; + created_at: number; + last_validated_at?: number | null; + api_key_set: boolean; +} + +export interface CreateConnectionRequest { + provider: string; + key: string; + label?: string; + models?: string[]; +} + +export interface UpdateConnectionRequest { + key?: string; + label?: string; + models?: string[]; +} + +export interface ValidateConnectionResponse { + id: string; + provider: string; + ok: boolean; + models: string[]; + error?: string | null; + validated_at: number; +} + export interface SettingsSchema { model_name: string; sections: Array>; From abd7a820fbb51b53d6438495c180d23f6bab01b0 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 11 Aug 2026 18:53:19 +0000 Subject: [PATCH 3/5] Address review: verified flag, live probe, profile-from-connection, typed disconnect - ValidateConnectionResponse gains a `verified` boolean so consumers can tell a real network check from a catalog-only lookup - validateConnection accepts { live } and forwards ?live=true - deleteConnection now returns DisconnectConnectionResponse (affected profiles) instead of void, matching the backend - add createProfileFromConnection + request/response types for the 'pick a model the provider offers' Agent Profile flow - export the new types from the package entrypoint - tests: live-flag query, profile-creation body, and a typed HttpError error-path assertion (status + parsed server body) Co-authored-by: openhands --- src/__tests__/connections-client.test.ts | 63 ++++++++++++++++++------ src/client/llm-client.ts | 52 +++++++++++++------ src/index.ts | 3 ++ src/models/api.ts | 25 ++++++++++ 4 files changed, 113 insertions(+), 30 deletions(-) diff --git a/src/__tests__/connections-client.test.ts b/src/__tests__/connections-client.test.ts index edf236c..0b2d0b0 100644 --- a/src/__tests__/connections-client.test.ts +++ b/src/__tests__/connections-client.test.ts @@ -7,10 +7,7 @@ */ import { LLMMetadataClient } from '../client/llm-client'; -import type { - ProviderConnection, - ValidateConnectionResponse, -} from '../models/api'; +import type { ProviderConnection, ValidateConnectionResponse } from '../models/api'; const originalFetch = global.fetch; @@ -125,9 +122,7 @@ describe('LLMMetadataClient connections', () => { await client.updateConnection('abc', { label: 'work', models: ['gpt-4o'] }); expect(calls[0].url).toBe('http://example.com/api/llm/connections/abc'); expect(calls[0].init?.method).toBe('PATCH'); - expect(calls[0].init?.body).toBe( - JSON.stringify({ label: 'work', models: ['gpt-4o'] }) - ); + expect(calls[0].init?.body).toBe(JSON.stringify({ label: 'work', models: ['gpt-4o'] })); }); it('updateConnection can rotate the key', async () => { @@ -139,13 +134,15 @@ describe('LLMMetadataClient connections', () => { expect(calls[0].init?.body).toBe(JSON.stringify({ key: 'sk-new' })); }); - it('deleteConnection DELETEs {id} and resolves', async () => { - const { fetch, calls } = captureFetch(); - global.fetch = fetch; + it('deleteConnection DELETEs {id} and returns affected profiles', async () => { + global.fetch = mockFetch({ id: 'abc', affected_profiles: ['work-gpt4o'] }); - await expect(client.deleteConnection('abc')).resolves.toBeUndefined(); - expect(calls[0].url).toBe('http://example.com/api/llm/connections/abc'); - expect(calls[0].init?.method).toBe('DELETE'); + const result = await client.deleteConnection('abc'); + expect(result).toEqual({ id: 'abc', affected_profiles: ['work-gpt4o'] }); + expect(global.fetch).toHaveBeenCalledWith( + 'http://example.com/api/llm/connections/abc', + expect.objectContaining({ method: 'DELETE' }) + ); }); it('validateConnection POSTs to {id}/validate and returns the response', async () => { @@ -153,6 +150,7 @@ describe('LLMMetadataClient connections', () => { id: 'abc', provider: 'openai', ok: true, + verified: false, models: ['gpt-4o', 'gpt-4o-mini'], error: null, validated_at: 1700000200, @@ -161,19 +159,52 @@ describe('LLMMetadataClient connections', () => { const result = await client.validateConnection('abc'); expect(result).toEqual(validate); + expect(result.verified).toBe(false); expect(global.fetch).toHaveBeenCalledWith( 'http://example.com/api/llm/connections/abc/validate', expect.objectContaining({ method: 'POST' }) ); }); + it('validateConnection forwards the live flag as a query param', async () => { + const { fetch, calls } = captureFetch(); + global.fetch = fetch; + + await client.validateConnection('abc', { live: true }); + expect(calls[0].url).toBe('http://example.com/api/llm/connections/abc/validate?live=true'); + expect(calls[0].init?.method).toBe('POST'); + }); + + it('createProfileFromConnection POSTs the profile body to {id}/profiles', async () => { + const { fetch, calls } = captureFetch(); + global.fetch = fetch; + + await client.createProfileFromConnection('abc', { + profile_name: 'work-gpt4o', + model: 'gpt-4o', + }); + expect(calls[0].url).toBe('http://example.com/api/llm/connections/abc/profiles'); + expect(calls[0].init?.method).toBe('POST'); + expect(calls[0].init?.body).toBe( + JSON.stringify({ profile_name: 'work-gpt4o', model: 'gpt-4o' }) + ); + }); + + 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.listConnections()).rejects.toMatchObject({ + name: 'HttpError', + status: 400, + response: { detail: 'provider unknown' }, + }); + }); + it('encodes the connection id in the path', async () => { const { fetch, calls } = captureFetch(); global.fetch = fetch; await client.getConnection('a/b c'); - expect(calls[0].url).toBe( - 'http://example.com/api/llm/connections/a%2Fb%20c' - ); + expect(calls[0].url).toBe('http://example.com/api/llm/connections/a%2Fb%20c'); }); }); diff --git a/src/client/llm-client.ts b/src/client/llm-client.ts index d6a66e0..2f0e2e8 100644 --- a/src/client/llm-client.ts +++ b/src/client/llm-client.ts @@ -1,11 +1,14 @@ import { HttpClient } from './http-client'; import { CreateConnectionRequest, + CreateProfileFromConnectionRequest, + DisconnectConnectionResponse, LLMSubscriptionDevicePollRequest, LLMSubscriptionDeviceStartResponse, LLMSubscriptionModelsResponse, LLMSubscriptionStatusResponse, ModelsResponse, + ProfileFromConnectionResponse, ProviderConnection, ProvidersResponse, UpdateConnectionRequest, @@ -96,19 +99,12 @@ export class LLMMetadataClient { // stored as a named secret server-side and never returned (api_key_set only). async listConnections(): Promise { - const response = await this.client.get( - '/api/llm/connections' - ); + const response = await this.client.get('/api/llm/connections'); return response.data; } - async createConnection( - body: CreateConnectionRequest - ): Promise { - const response = await this.client.post( - '/api/llm/connections', - body - ); + async createConnection(body: CreateConnectionRequest): Promise { + const response = await this.client.post('/api/llm/connections', body); return response.data; } @@ -130,17 +126,45 @@ export class LLMMetadataClient { return response.data; } - async deleteConnection(connectionId: string): Promise { - await this.client.delete( + /** + * Disconnect a connection. Returns the LLM profiles that referenced its key + * (they will need a new key before they can authenticate again). + */ + async deleteConnection(connectionId: string): Promise { + const response = await this.client.delete( `/api/llm/connections/${encodeURIComponent(connectionId)}` ); + return response.data; } + /** + * Validate a connection's key against its provider. Pass `live` to issue a + * real network probe; the response's `verified` flag reflects whether that + * happened (catalog-only validation returns `verified: false`). + */ async validateConnection( - connectionId: string + connectionId: string, + options?: { live?: boolean } ): Promise { + const query = options?.live ? '?live=true' : ''; const response = await this.client.post( - `/api/llm/connections/${encodeURIComponent(connectionId)}/validate` + `/api/llm/connections/${encodeURIComponent(connectionId)}/validate${query}` + ); + return response.data; + } + + /** + * Create an LLM profile bound to this connection's key. The profile stores an + * `api_key` reference to the connection's secret rather than the raw key, so + * rotating the connection updates every profile spawned from it. + */ + async createProfileFromConnection( + connectionId: string, + body: CreateProfileFromConnectionRequest + ): Promise { + const response = await this.client.post( + `/api/llm/connections/${encodeURIComponent(connectionId)}/profiles`, + body ); return response.data; } diff --git a/src/index.ts b/src/index.ts index cc2a460..02fe7d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -420,6 +420,9 @@ export type { CreateConnectionRequest, UpdateConnectionRequest, ValidateConnectionResponse, + DisconnectConnectionResponse, + CreateProfileFromConnectionRequest, + ProfileFromConnectionResponse, } from './models/api'; export type { WebSocketClientOptions } from './events/websocket-client'; diff --git a/src/models/api.ts b/src/models/api.ts index 3efcf58..0fdde37 100644 --- a/src/models/api.ts +++ b/src/models/api.ts @@ -88,11 +88,36 @@ export interface ValidateConnectionResponse { id: string; provider: string; ok: boolean; + /** + * True only when the key was checked against the provider over the network. + * When false, `models` is the provider's advertised catalog rather than a + * proven grant — clients must not present the key as authenticated. + */ + verified: boolean; models: string[]; error?: string | null; validated_at: number; } +export interface DisconnectConnectionResponse { + id: string; + /** LLM profiles that referenced the deleted connection's key. */ + affected_profiles: string[]; +} + +export interface CreateProfileFromConnectionRequest { + profile_name: string; + model: string; + base_url?: string | null; +} + +export interface ProfileFromConnectionResponse { + profile_name: string; + model: string; + provider: string; + connection_id: string; +} + export interface SettingsSchema { model_name: string; sections: Array>; From a93beb2131c006080748a6ddd838f12169093ef4 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 12 Aug 2026 18:44:13 -0300 Subject: [PATCH 4/5] Add endpoint settings to provider connection types --- src/__tests__/connections-client.test.ts | 35 ++++++++++++++++++++++-- src/models/api.ts | 9 ++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/__tests__/connections-client.test.ts b/src/__tests__/connections-client.test.ts index 0b2d0b0..0df01cc 100644 --- a/src/__tests__/connections-client.test.ts +++ b/src/__tests__/connections-client.test.ts @@ -58,6 +58,9 @@ describe('LLMMetadataClient connections', () => { id: 'abc', provider: 'openai', label: 'work', + base_url: 'https://proxy.example/v1', + api_mode: 'chat', + custom_headers: { 'X-Org': 'eng' }, models: ['gpt-4o'], created_at: 1700000000, last_validated_at: 1700000100, @@ -78,6 +81,9 @@ describe('LLMMetadataClient connections', () => { const created: ProviderConnection = { id: 'abc', provider: 'openai', + base_url: 'https://proxy.example/v1', + api_mode: 'responses', + custom_headers: { 'X-Org': 'eng' }, models: [], created_at: 1700000000, api_key_set: true, @@ -87,13 +93,22 @@ describe('LLMMetadataClient connections', () => { const result = await client.createConnection({ provider: 'openai', key: 'sk-test', + base_url: 'https://proxy.example/v1', + api_mode: 'responses', + custom_headers: { 'X-Org': 'eng' }, }); expect(result).toEqual(created); expect(global.fetch).toHaveBeenCalledWith( 'http://example.com/api/llm/connections', expect.objectContaining({ method: 'POST', - body: JSON.stringify({ provider: 'openai', key: 'sk-test' }), + body: JSON.stringify({ + provider: 'openai', + key: 'sk-test', + base_url: 'https://proxy.example/v1', + api_mode: 'responses', + custom_headers: { 'X-Org': 'eng' }, + }), }) ); }); @@ -119,10 +134,24 @@ describe('LLMMetadataClient connections', () => { const { fetch, calls } = captureFetch(); global.fetch = fetch; - await client.updateConnection('abc', { label: 'work', models: ['gpt-4o'] }); + await client.updateConnection('abc', { + label: 'work', + base_url: 'https://proxy.example/v1', + api_mode: 'chat', + custom_headers: { 'X-Org': 'eng' }, + models: ['gpt-4o'], + }); expect(calls[0].url).toBe('http://example.com/api/llm/connections/abc'); expect(calls[0].init?.method).toBe('PATCH'); - expect(calls[0].init?.body).toBe(JSON.stringify({ label: 'work', models: ['gpt-4o'] })); + expect(calls[0].init?.body).toBe( + JSON.stringify({ + label: 'work', + base_url: 'https://proxy.example/v1', + api_mode: 'chat', + custom_headers: { 'X-Org': 'eng' }, + models: ['gpt-4o'], + }) + ); }); it('updateConnection can rotate the key', async () => { diff --git a/src/models/api.ts b/src/models/api.ts index 0fdde37..be40ccb 100644 --- a/src/models/api.ts +++ b/src/models/api.ts @@ -65,6 +65,9 @@ export interface ProviderConnection { id: string; provider: string; label?: string; + base_url?: string | null; + api_mode?: 'auto' | 'chat' | 'responses'; + custom_headers?: Record; models: string[]; created_at: number; last_validated_at?: number | null; @@ -75,12 +78,18 @@ export interface CreateConnectionRequest { provider: string; key: string; label?: string; + base_url?: string | null; + api_mode?: 'auto' | 'chat' | 'responses'; + custom_headers?: Record; models?: string[]; } export interface UpdateConnectionRequest { key?: string; label?: string; + base_url?: string | null; + api_mode?: 'auto' | 'chat' | 'responses'; + custom_headers?: Record; models?: string[]; } From 7669af5b0ccbe8b716fb4cfd01cbb058a752eeee Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 14 Aug 2026 05:21:34 +0000 Subject: [PATCH 5/5] Rework provider connection client into provider-first model providers Align the typed client with the reworked backend (software-agent-sdk#4455): providers hold one key (named secret) and a nested, user-managed model list. - api.ts: replace ProviderConnection/*Connection* types with ModelProvider, ProviderModel, Create/UpdateProviderRequest, ProviderModelPayload and TestProviderResponse (masked view: api_key_set only, no secret_name). - llm-client.ts: replace the connection + validate + profile-from-connection methods with provider CRUD, nested model CRUD (add/update/remove) and testProvider (optional key probe; never mutates the curated model list), all under /api/llm/model-providers. - index.ts: export the new provider types. - Replace connections-client.test.ts with providers-client.test.ts (12 tests). Refs OpenHands/OpenHands#15492. Co-authored-by: openhands --- src/__tests__/connections-client.test.ts | 239 -------------------- src/__tests__/providers-client.test.ts | 270 +++++++++++++++++++++++ src/client/llm-client.ts | 120 +++++----- src/index.ts | 14 +- src/models/api.ts | 101 +++++---- 5 files changed, 400 insertions(+), 344 deletions(-) delete mode 100644 src/__tests__/connections-client.test.ts create mode 100644 src/__tests__/providers-client.test.ts diff --git a/src/__tests__/connections-client.test.ts b/src/__tests__/connections-client.test.ts deleted file mode 100644 index 0df01cc..0000000 --- a/src/__tests__/connections-client.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -/** - * Tests for the Provider Connection client methods on LLMMetadataClient. - * - * Verifies each /api/llm/connections 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 { ProviderConnection, ValidateConnectionResponse } 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 connections', () => { - let client: LLMMetadataClient; - - beforeEach(() => { - client = new LLMMetadataClient({ - host: 'http://example.com', - apiKey: 'secret', - }); - }); - - afterEach(() => { - global.fetch = originalFetch; - jest.restoreAllMocks(); - }); - - it('listConnections GETs /api/llm/connections and returns the array', async () => { - const conns: ProviderConnection[] = [ - { - id: 'abc', - provider: 'openai', - label: 'work', - base_url: 'https://proxy.example/v1', - api_mode: 'chat', - custom_headers: { 'X-Org': 'eng' }, - models: ['gpt-4o'], - created_at: 1700000000, - last_validated_at: 1700000100, - api_key_set: true, - }, - ]; - global.fetch = mockFetch(conns); - - const result = await client.listConnections(); - expect(result).toEqual(conns); - expect(global.fetch).toHaveBeenCalledWith( - 'http://example.com/api/llm/connections', - expect.objectContaining({ method: 'GET' }) - ); - }); - - it('createConnection POSTs body to /api/llm/connections', async () => { - const created: ProviderConnection = { - id: 'abc', - provider: 'openai', - base_url: 'https://proxy.example/v1', - api_mode: 'responses', - custom_headers: { 'X-Org': 'eng' }, - models: [], - created_at: 1700000000, - api_key_set: true, - }; - global.fetch = mockFetch(created, 201); - - const result = await client.createConnection({ - provider: 'openai', - key: 'sk-test', - base_url: 'https://proxy.example/v1', - api_mode: 'responses', - custom_headers: { 'X-Org': 'eng' }, - }); - expect(result).toEqual(created); - expect(global.fetch).toHaveBeenCalledWith( - 'http://example.com/api/llm/connections', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ - provider: 'openai', - key: 'sk-test', - base_url: 'https://proxy.example/v1', - api_mode: 'responses', - custom_headers: { 'X-Org': 'eng' }, - }), - }) - ); - }); - - it('getConnection GETs /api/llm/connections/{id}', async () => { - const conn: ProviderConnection = { - id: 'abc', - provider: 'openai', - models: [], - created_at: 1, - api_key_set: true, - }; - global.fetch = mockFetch(conn); - - await client.getConnection('abc'); - expect(global.fetch).toHaveBeenCalledWith( - 'http://example.com/api/llm/connections/abc', - expect.objectContaining({ method: 'GET' }) - ); - }); - - it('updateConnection PATCHes {id} with the partial body', async () => { - const { fetch, calls } = captureFetch(); - global.fetch = fetch; - - await client.updateConnection('abc', { - label: 'work', - base_url: 'https://proxy.example/v1', - api_mode: 'chat', - custom_headers: { 'X-Org': 'eng' }, - models: ['gpt-4o'], - }); - expect(calls[0].url).toBe('http://example.com/api/llm/connections/abc'); - expect(calls[0].init?.method).toBe('PATCH'); - expect(calls[0].init?.body).toBe( - JSON.stringify({ - label: 'work', - base_url: 'https://proxy.example/v1', - api_mode: 'chat', - custom_headers: { 'X-Org': 'eng' }, - models: ['gpt-4o'], - }) - ); - }); - - it('updateConnection can rotate the key', async () => { - const { fetch, calls } = captureFetch(); - global.fetch = fetch; - - await client.updateConnection('abc', { key: 'sk-new' }); - expect(calls[0].init?.method).toBe('PATCH'); - expect(calls[0].init?.body).toBe(JSON.stringify({ key: 'sk-new' })); - }); - - it('deleteConnection DELETEs {id} and returns affected profiles', async () => { - global.fetch = mockFetch({ id: 'abc', affected_profiles: ['work-gpt4o'] }); - - const result = await client.deleteConnection('abc'); - expect(result).toEqual({ id: 'abc', affected_profiles: ['work-gpt4o'] }); - expect(global.fetch).toHaveBeenCalledWith( - 'http://example.com/api/llm/connections/abc', - expect.objectContaining({ method: 'DELETE' }) - ); - }); - - it('validateConnection POSTs to {id}/validate and returns the response', async () => { - const validate: ValidateConnectionResponse = { - id: 'abc', - provider: 'openai', - ok: true, - verified: false, - models: ['gpt-4o', 'gpt-4o-mini'], - error: null, - validated_at: 1700000200, - }; - global.fetch = mockFetch(validate); - - const result = await client.validateConnection('abc'); - expect(result).toEqual(validate); - expect(result.verified).toBe(false); - expect(global.fetch).toHaveBeenCalledWith( - 'http://example.com/api/llm/connections/abc/validate', - expect.objectContaining({ method: 'POST' }) - ); - }); - - it('validateConnection forwards the live flag as a query param', async () => { - const { fetch, calls } = captureFetch(); - global.fetch = fetch; - - await client.validateConnection('abc', { live: true }); - expect(calls[0].url).toBe('http://example.com/api/llm/connections/abc/validate?live=true'); - expect(calls[0].init?.method).toBe('POST'); - }); - - it('createProfileFromConnection POSTs the profile body to {id}/profiles', async () => { - const { fetch, calls } = captureFetch(); - global.fetch = fetch; - - await client.createProfileFromConnection('abc', { - profile_name: 'work-gpt4o', - model: 'gpt-4o', - }); - expect(calls[0].url).toBe('http://example.com/api/llm/connections/abc/profiles'); - expect(calls[0].init?.method).toBe('POST'); - expect(calls[0].init?.body).toBe( - JSON.stringify({ profile_name: 'work-gpt4o', model: 'gpt-4o' }) - ); - }); - - 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.listConnections()).rejects.toMatchObject({ - name: 'HttpError', - status: 400, - response: { detail: 'provider unknown' }, - }); - }); - - it('encodes the connection id in the path', async () => { - const { fetch, calls } = captureFetch(); - global.fetch = fetch; - - await client.getConnection('a/b c'); - expect(calls[0].url).toBe('http://example.com/api/llm/connections/a%2Fb%20c'); - }); -}); 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 2f0e2e8..46d9cb6 100644 --- a/src/client/llm-client.ts +++ b/src/client/llm-client.ts @@ -1,18 +1,16 @@ import { HttpClient } from './http-client'; import { - CreateConnectionRequest, - CreateProfileFromConnectionRequest, - DisconnectConnectionResponse, + CreateProviderRequest, LLMSubscriptionDevicePollRequest, LLMSubscriptionDeviceStartResponse, LLMSubscriptionModelsResponse, LLMSubscriptionStatusResponse, + ModelProvider, ModelsResponse, - ProfileFromConnectionResponse, - ProviderConnection, + ProviderModelPayload, ProvidersResponse, - UpdateConnectionRequest, - ValidateConnectionResponse, + TestProviderResponse, + UpdateProviderRequest, VerifiedModelsResponse, } from '../models/api'; @@ -93,78 +91,96 @@ export class LLMMetadataClient { return response.data; } - // ── Provider Connections (/api/llm/connections) ─────────────────────── + // ── Model Providers (/api/llm/model-providers) ──────────────────────── // - // Connect a vendor once with one key, pick from its model catalog. The key is - // stored as a named secret server-side and never returned (api_key_set only). + // 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 listConnections(): Promise { - const response = await this.client.get('/api/llm/connections'); + async listProviders(): Promise { + const response = await this.client.get('/api/llm/model-providers'); return response.data; } - async createConnection(body: CreateConnectionRequest): Promise { - const response = await this.client.post('/api/llm/connections', body); + async createProvider(body: CreateProviderRequest): Promise { + const response = await this.client.post('/api/llm/model-providers', body); return response.data; } - async getConnection(connectionId: string): Promise { - const response = await this.client.get( - `/api/llm/connections/${encodeURIComponent(connectionId)}` + async getProvider(providerId: string): Promise { + const response = await this.client.get( + `/api/llm/model-providers/${encodeURIComponent(providerId)}` ); return response.data; } - async updateConnection( - connectionId: string, - body: UpdateConnectionRequest - ): Promise { - const response = await this.client.patch( - `/api/llm/connections/${encodeURIComponent(connectionId)}`, + /** 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; } - /** - * Disconnect a connection. Returns the LLM profiles that referenced its key - * (they will need a new key before they can authenticate again). - */ - async deleteConnection(connectionId: string): Promise { - const response = await this.client.delete( - `/api/llm/connections/${encodeURIComponent(connectionId)}` + /** 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; } - /** - * Validate a connection's key against its provider. Pass `live` to issue a - * real network probe; the response's `verified` flag reflects whether that - * happened (catalog-only validation returns `verified: false`). - */ - async validateConnection( - connectionId: string, - options?: { live?: boolean } - ): Promise { - const query = options?.live ? '?live=true' : ''; - const response = await this.client.post( - `/api/llm/connections/${encodeURIComponent(connectionId)}/validate${query}` + /** 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; } /** - * Create an LLM profile bound to this connection's key. The profile stores an - * `api_key` reference to the connection's secret rather than the raw key, so - * rotating the connection updates every profile spawned from it. + * 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 createProfileFromConnection( - connectionId: string, - body: CreateProfileFromConnectionRequest - ): Promise { - const response = await this.client.post( - `/api/llm/connections/${encodeURIComponent(connectionId)}/profiles`, - body + async testProvider(providerId: string): Promise { + const response = await this.client.post( + `/api/llm/model-providers/${encodeURIComponent(providerId)}/test` ); return response.data; } diff --git a/src/index.ts b/src/index.ts index 02fe7d3..5a4754a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -416,13 +416,13 @@ export type { MCPOAuthCallbackRequest, SharedConversation, EventPage as ApiEventPage, - ProviderConnection, - CreateConnectionRequest, - UpdateConnectionRequest, - ValidateConnectionResponse, - DisconnectConnectionResponse, - CreateProfileFromConnectionRequest, - ProfileFromConnectionResponse, + 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 be40ccb..7492abe 100644 --- a/src/models/api.ts +++ b/src/models/api.ts @@ -55,76 +55,85 @@ export interface LLMSubscriptionModelsResponse { models: string[]; } -// ── Provider Connections (OpenHands/OpenHands#15492) ──────────────────── +// ── Model Providers (OpenHands/OpenHands#15492) ───────────────────────── // -// A Provider Connection is the persisted record for "connect a vendor once -// with one key, pick from its model catalog". The key is stored as a named -// secret server-side; these responses never echo it (api_key_set only). +// 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; +} -export interface ProviderConnection { +/** Masked provider view — never includes the raw key or `secret_name`. */ +export interface ModelProvider { id: string; - provider: string; - label?: string; + display_name: string; + /** Preset id or litellm provider key, e.g. 'openai', 'anthropic', 'custom'. */ + kind: string; base_url?: string | null; - api_mode?: 'auto' | 'chat' | 'responses'; - custom_headers?: Record; - models: string[]; + wire_api: WireApi; + custom_headers: Record; + models: ProviderModel[]; created_at: number; - last_validated_at?: number | null; + updated_at: number; + /** True when a key is stored; the key itself is never returned. */ api_key_set: boolean; } -export interface CreateConnectionRequest { - provider: string; +export interface CreateProviderRequest { + display_name: string; + kind?: string; + /** Written to the SecretsStore; never echoed back. */ key: string; - label?: string; base_url?: string | null; - api_mode?: 'auto' | 'chat' | 'responses'; + wire_api?: WireApi; custom_headers?: Record; - models?: string[]; + /** Optional models to seed the provider with. */ + models?: ProviderModel[]; } -export interface UpdateConnectionRequest { +/** Partial update. Provide at least one field. `key` rotates the named secret. */ +export interface UpdateProviderRequest { + display_name?: string; + kind?: string; key?: string; - label?: string; base_url?: string | null; - api_mode?: 'auto' | 'chat' | 'responses'; + wire_api?: WireApi; custom_headers?: Record; - models?: string[]; } -export interface ValidateConnectionResponse { +/** 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; - provider: string; ok: boolean; /** - * True only when the key was checked against the provider over the network. - * When false, `models` is the provider's advertised catalog rather than a - * proven grant — clients must not present the key as authenticated. + * 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; - models: string[]; + suggested_models: string[]; error?: string | null; - validated_at: number; -} - -export interface DisconnectConnectionResponse { - id: string; - /** LLM profiles that referenced the deleted connection's key. */ - affected_profiles: string[]; -} - -export interface CreateProfileFromConnectionRequest { - profile_name: string; - model: string; - base_url?: string | null; -} - -export interface ProfileFromConnectionResponse { - profile_name: string; - model: string; - provider: string; - connection_id: string; } export interface SettingsSchema {