summaryrefslogtreecommitdiffhomepage
path: root/packages/provider-openai-compat/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-05 21:20:12 +0900
committerAdam Malczewski <[email protected]>2026-06-05 21:20:12 +0900
commit7fb3269c698ae583ea7997ce206c4ae252fd3218 (patch)
tree247d03408ecccd633290ea56b1b08811ebe460ec /packages/provider-openai-compat/src
parent4283d1f8a0bc3953e65962a2364c903d0015f047 (diff)
downloaddispatch-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.ts1
-rw-r--r--packages/provider-openai-compat/src/listModels.test.ts101
-rw-r--r--packages/provider-openai-compat/src/listModels.ts67
-rw-r--r--packages/provider-openai-compat/src/provider.ts37
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 } : {}),
+ }),
};
}