diff options
| author | Adam Malczewski <[email protected]> | 2026-06-05 21:20:12 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-05 21:20:12 +0900 |
| commit | 7fb3269c698ae583ea7997ce206c4ae252fd3218 (patch) | |
| tree | 247d03408ecccd633290ea56b1b08811ebe460ec /packages/provider-openai-compat/src | |
| parent | 4283d1f8a0bc3953e65962a2364c903d0015f047 (diff) | |
| download | dispatch-7fb3269c698ae583ea7997ce206c4ae252fd3218.tar.gz dispatch-7fb3269c698ae583ea7997ce206c4ae252fd3218.zip | |
feat(backend): credential-store + model selection/catalog (GET /models) + per-turn cwd through orchestrator/transport/host-bin
Diffstat (limited to 'packages/provider-openai-compat/src')
| -rw-r--r-- | packages/provider-openai-compat/src/index.ts | 1 | ||||
| -rw-r--r-- | packages/provider-openai-compat/src/listModels.test.ts | 101 | ||||
| -rw-r--r-- | packages/provider-openai-compat/src/listModels.ts | 67 | ||||
| -rw-r--r-- | packages/provider-openai-compat/src/provider.ts | 37 |
4 files changed, 202 insertions, 4 deletions
diff --git a/packages/provider-openai-compat/src/index.ts b/packages/provider-openai-compat/src/index.ts index f35f2e9..3498a9d 100644 --- a/packages/provider-openai-compat/src/index.ts +++ b/packages/provider-openai-compat/src/index.ts @@ -3,6 +3,7 @@ export { convertMessages } from "./convert-messages.js"; export type { OpenAITool } from "./convert-tools.js"; export { convertTools } from "./convert-tools.js"; export { activate, extension, manifest } from "./extension.js"; +export { parseModelList } from "./listModels.js"; export { parseSSELines } from "./parse-sse.js"; export type { CreateOpenAICompatProviderOpts } from "./provider.js"; export { createOpenAICompatProvider } from "./provider.js"; diff --git a/packages/provider-openai-compat/src/listModels.test.ts b/packages/provider-openai-compat/src/listModels.test.ts new file mode 100644 index 0000000..97badaa --- /dev/null +++ b/packages/provider-openai-compat/src/listModels.test.ts @@ -0,0 +1,101 @@ +import type { ApiKeyCredentials, ModelInfo, ProviderContract } from "@dispatch/kernel"; +import type { FetchLike } from "@dispatch/trace-replay"; +import { describe, expect, it, vi } from "vitest"; +import { parseModelList } from "./listModels.js"; +import { createOpenAICompatProvider } from "./provider.js"; + +function makeProvider(fetchFn: FetchLike, apiKey = "sk-test-1234567890abcdef"): ProviderContract { + const creds: ApiKeyCredentials = { + type: "api-key", + apiKey, + baseURL: "https://api.example.com/v1", + }; + return createOpenAICompatProvider({ + credentials: creds, + model: "test-model", + fetchFn, + }); +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("listModels — pure mapping (parseModelList)", () => { + it("maps OpenAI model entries to ModelInfo", () => { + const result = parseModelList([{ id: "a" }, { id: "b" }]); + expect(result).toEqual([{ id: "a" }, { id: "b" }]); + }); + + it("returns empty array for empty input", () => { + const result = parseModelList([]); + expect(result).toEqual([]); + }); +}); + +describe("listModels — provider contract", () => { + it("GETs models endpoint with bearer key and returns mapped ModelInfo[]", async () => { + const fetchFn = vi.fn( + () => jsonResponse({ data: [{ id: "a" }, { id: "b" }] }) as unknown as ReturnType<FetchLike>, + ); + const provider = makeProvider(fetchFn); + const listModels = provider.listModels; + if (!listModels) throw new Error("listModels not defined"); + + const models = await listModels(); + + expect(fetchFn).toHaveBeenCalledOnce(); + const callArgs = fetchFn.mock.calls[0]; + if (!callArgs) throw new Error("no call args"); + const [url, init] = callArgs as unknown as [string, RequestInit]; + expect(url).toBe("https://api.example.com/v1/models"); + expect(init.method).toBe("GET"); + expect(init.headers).toEqual({ Authorization: "Bearer sk-test-1234567890abcdef" }); + + expect(models).toEqual([{ id: "a" }, { id: "b" }] as readonly ModelInfo[]); + }); + + it("throws on non-OK HTTP status with a clear message", async () => { + const fetchFn = vi.fn( + () => + new Response("Unauthorized", { + status: 401, + headers: { "Content-Type": "text/plain" }, + }) as unknown as ReturnType<FetchLike>, + ); + const provider = makeProvider(fetchFn); + const listModels = provider.listModels; + if (!listModels) throw new Error("listModels not defined"); + + await expect(listModels()).rejects.toThrow( + "listModels[openai-compat]: HTTP 401 — Unauthorized", + ); + }); + + it("throws on network error with a clear message", async () => { + const fetchFn = vi.fn(() => { + throw new Error("connection refused"); + }) as unknown as FetchLike; + const provider = makeProvider(fetchFn); + const listModels = provider.listModels; + if (!listModels) throw new Error("listModels not defined"); + + await expect(listModels()).rejects.toThrow( + "listModels[openai-compat]: network error — connection refused", + ); + }); + + it("throws when response shape is missing data array", async () => { + const fetchFn = vi.fn(() => jsonResponse({ models: [] }) as unknown as ReturnType<FetchLike>); + const provider = makeProvider(fetchFn); + const listModels = provider.listModels; + if (!listModels) throw new Error("listModels not defined"); + + await expect(listModels()).rejects.toThrow( + 'listModels[openai-compat]: unexpected response shape — missing "data" array', + ); + }); +}); diff --git a/packages/provider-openai-compat/src/listModels.ts b/packages/provider-openai-compat/src/listModels.ts new file mode 100644 index 0000000..d253ebe --- /dev/null +++ b/packages/provider-openai-compat/src/listModels.ts @@ -0,0 +1,67 @@ +import type { ModelInfo } from "@dispatch/kernel"; +import type { FetchLike } from "@dispatch/trace-replay"; + +/** + * opencode-go specifics (model-list URL, usage/cache-token mapping, headers) + * live in this generic `provider-openai-compat` for now. When a SECOND + * OpenAI-compatible backend lands, split this into a generic OpenAI-stream + * capability exposed as a typed SERVICE handle and a `provider-opencode-go` + * extension that `dependsOn` it and layers the specifics — coupling via the + * typed handle only (isolation-over-DRY: no cross-extension code import). + */ + +interface OpenAIModelEntry { + readonly id: string; +} + +interface OpenAIModelListResponse { + readonly data: readonly OpenAIModelEntry[]; +} + +/** + * Pure mapping: raw OpenAI-compatible model list → ModelInfo[]. + * Extracted for direct unit testing with no I/O. + */ +export function parseModelList(data: readonly OpenAIModelEntry[]): readonly ModelInfo[] { + return data.map((entry) => ({ id: entry.id })); +} + +export interface ListModelsConfig { + readonly baseURL: string; + readonly apiKey: string; + readonly fetchFn?: FetchLike; + readonly providerId: string; +} + +export async function listModels(config: ListModelsConfig): Promise<readonly ModelInfo[]> { + const effectiveFetch: FetchLike = config.fetchFn ?? fetch; + const url = `${config.baseURL}/models`; + + let response: Response; + try { + response = await effectiveFetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${config.apiKey}`, + }, + }); + } catch (err) { + throw new Error( + `listModels[${config.providerId}]: network error — ${err instanceof Error ? err.message : String(err)}`, + ); + } + + if (!response.ok) { + const text = await response.text().catch(() => "unknown"); + throw new Error(`listModels[${config.providerId}]: HTTP ${response.status} — ${text}`); + } + + const body = (await response.json()) as OpenAIModelListResponse; + if (!Array.isArray(body.data)) { + throw new Error( + `listModels[${config.providerId}]: unexpected response shape — missing "data" array`, + ); + } + + return parseModelList(body.data); +} diff --git a/packages/provider-openai-compat/src/provider.ts b/packages/provider-openai-compat/src/provider.ts index 8f0ddda..19c29a1 100644 --- a/packages/provider-openai-compat/src/provider.ts +++ b/packages/provider-openai-compat/src/provider.ts @@ -1,22 +1,44 @@ import type { ApiKeyCredentials, ChatMessage, + ModelInfo, ProviderContract, ProviderStreamOptions, ToolContract, } from "@dispatch/kernel"; +import type { FetchLike } from "@dispatch/trace-replay"; +import { listModels as fetchModels } from "./listModels.js"; import { streamChat } from "./stream.js"; +/** + * opencode-go specifics (model-list URL, usage/cache-token mapping, headers) + * live in this generic `provider-openai-compat` for now. When a SECOND + * OpenAI-compatible backend lands, split this into a generic OpenAI-stream + * capability exposed as a typed SERVICE handle and a `provider-opencode-go` + * extension that `dependsOn` it and layers the specifics — coupling via the + * typed handle only (isolation-over-DRY: no cross-extension code import). + */ + export interface CreateOpenAICompatProviderOpts { readonly credentials: ApiKeyCredentials; readonly model: string; + /** + * Internal injectable fetch — used by tests and replay mode. + * When absent, falls back to globalThis.fetch (production default). + */ + readonly fetchFn?: FetchLike; } export function createOpenAICompatProvider(opts: CreateOpenAICompatProviderOpts): ProviderContract { - const config = { - baseURL: opts.credentials.baseURL ?? "https://opencode.ai/zen/go/v1", - apiKey: opts.credentials.apiKey, + const baseURL = opts.credentials.baseURL ?? "https://opencode.ai/zen/go/v1"; + const apiKey = opts.credentials.apiKey; + const fetchFn = opts.fetchFn; + + const streamConfig = { + baseURL, + apiKey, model: opts.model, + ...(fetchFn !== undefined ? { fetchFn } : {}), }; return { @@ -25,6 +47,13 @@ export function createOpenAICompatProvider(opts: CreateOpenAICompatProviderOpts) messages: readonly ChatMessage[], tools: readonly ToolContract[], streamOpts?: ProviderStreamOptions, - ) => streamChat(config, messages, tools, streamOpts), + ) => streamChat(streamConfig, messages, tools, streamOpts), + listModels: (): Promise<readonly ModelInfo[]> => + fetchModels({ + baseURL, + apiKey, + providerId: "openai-compat", + ...(fetchFn !== undefined ? { fetchFn } : {}), + }), }; } |
