diff options
| author | Adam Malczewski <[email protected]> | 2026-06-21 12:09:09 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-21 12:09:09 +0900 |
| commit | d23de3254374d4d63c8e15c6ab9311c3c6f4da5b (patch) | |
| tree | 6105e3a8639555b2c925d412a567bcb8caa4075f | |
| parent | ba47df37f0c89bff4f0c3dd7d0bc2ef6c8062b92 (diff) | |
| download | dispatch-d23de3254374d4d63c8e15c6ab9311c3c6f4da5b.tar.gz dispatch-d23de3254374d4d63c8e15c6ab9311c3c6f4da5b.zip | |
feat(provider-umans): Umans AI Coding Plan provider + openai-stream lib
Extract a generic @dispatch/openai-stream library from provider-openai-compat
(convert-messages, convert-tools, parse-sse, listModels, stream, provider),
parameterizing createOpenAICompatProvider with uid=1000(tradam) gid=1000(tradam) groups=1000(tradam),966(docker),968(ollama),998(wheel) + hook.
Refactor provider-openai-compat to import from the lib (byte-identical behavior).
New @dispatch/provider-umans extension wraps the Umans OpenAI-compatible backend
(https://api.code.umans.ai/v1). Self-contained: reads UMANS_API_KEY from env
directly (no auth-apikey dep). transformBody maps reasoningEffort →
reasoning_effort (capping xhigh/max → high). Dynamic listModels via GET /v1/models.
host-bin: registered provider-umans in CORE_EXTENSIONS + umans credential
(gated on UMANS_API_KEY — the credential is the model-catalog index).
Verified: tsc EXIT 0, 1059 vitest, biome clean (293 files). Boot smoke:
umans models appear in GET /models (7 models live).
36 files changed, 619 insertions, 26 deletions
@@ -35,6 +35,11 @@ bun install DISPATCH_MODEL=deepseek-v4-flash # default model when a request omits one BACKEND_PORT=24203 # port the HTTP server listens on FRONTEND_PORT=24204 # reserved for the future web UI + + # Optional — Umans AI Coding Plan provider (https://code.umans.ai) + UMANS_API_KEY=sk-... # if set, the "umans" provider is registered + # UMANS_BASE_URL=https://api.code.umans.ai/v1 # override the default base URL + # UMANS_MODEL=umans-coder # default model (umans-coder|umans-kimi-k2.7|umans-glm-5.2|umans-flash) ``` Bun auto-loads `.env`. (If your shell also needs the vars: `set -a; source .env; set +a`.) @@ -61,7 +61,9 @@ "@dispatch/journal-sink": "workspace:*", "@dispatch/kernel": "workspace:*", "@dispatch/lsp": "workspace:*", + "@dispatch/message-queue": "workspace:*", "@dispatch/provider-openai-compat": "workspace:*", + "@dispatch/provider-umans": "workspace:*", "@dispatch/session-orchestrator": "workspace:*", "@dispatch/skills": "workspace:*", "@dispatch/storage-sqlite": "workspace:*", @@ -115,14 +117,32 @@ "@dispatch/trace-store": "workspace:*", }, }, + "packages/openai-stream": { + "name": "@dispatch/openai-stream", + "version": "0.0.0", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/trace-replay": "workspace:*", + "@dispatch/wire": "workspace:*", + }, + }, "packages/provider-openai-compat": { "name": "@dispatch/provider-openai-compat", "version": "0.0.0", "dependencies": { "@dispatch/kernel": "workspace:*", + "@dispatch/openai-stream": "workspace:*", "@dispatch/trace-replay": "workspace:*", }, }, + "packages/provider-umans": { + "name": "@dispatch/provider-umans", + "version": "0.0.0", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/openai-stream": "workspace:*", + }, + }, "packages/session-orchestrator": { "name": "@dispatch/session-orchestrator", "version": "0.0.0", @@ -301,8 +321,12 @@ "@dispatch/observability-collector": ["@dispatch/observability-collector@workspace:packages/observability-collector"], + "@dispatch/openai-stream": ["@dispatch/openai-stream@workspace:packages/openai-stream"], + "@dispatch/provider-openai-compat": ["@dispatch/provider-openai-compat@workspace:packages/provider-openai-compat"], + "@dispatch/provider-umans": ["@dispatch/provider-umans@workspace:packages/provider-umans"], + "@dispatch/session-orchestrator": ["@dispatch/session-orchestrator@workspace:packages/session-orchestrator"], "@dispatch/skills": ["@dispatch/skills@workspace:packages/skills"], diff --git a/packages/host-bin/package.json b/packages/host-bin/package.json index d55b369..63b78bc 100644 --- a/packages/host-bin/package.json +++ b/packages/host-bin/package.json @@ -11,6 +11,7 @@ "@dispatch/cache-warming": "workspace:*", "@dispatch/credential-store": "workspace:*", "@dispatch/provider-openai-compat": "workspace:*", + "@dispatch/provider-umans": "workspace:*", "@dispatch/message-queue": "workspace:*", "@dispatch/session-orchestrator": "workspace:*", "@dispatch/skills": "workspace:*", diff --git a/packages/host-bin/src/main.ts b/packages/host-bin/src/main.ts index 59ce47d..1928a8a 100644 --- a/packages/host-bin/src/main.ts +++ b/packages/host-bin/src/main.ts @@ -22,6 +22,7 @@ import { import { extension as lspExt } from "@dispatch/lsp"; import { extension as messageQueueExt } from "@dispatch/message-queue"; import { extension as providerOpenaiCompatExt } from "@dispatch/provider-openai-compat"; +import { extension as providerUmansExt } from "@dispatch/provider-umans"; import { extension as sessionOrchestratorExt } from "@dispatch/session-orchestrator"; import { extension as skillsExt } from "@dispatch/skills"; import { createSqliteStorage, extension as storageSqliteExt } from "@dispatch/storage-sqlite"; @@ -69,6 +70,7 @@ const CORE_EXTENSIONS: readonly Extension[] = [ conversationStoreExt, authApikeyExt, providerOpenaiCompatExt, + providerUmansExt, toolEditFileExt, toolReadFileExt, toolShellExt, @@ -148,6 +150,14 @@ async function boot(): Promise<void> { // Assemble the credential list. MVP keeps the hardcoded `opencode` credential // and adds a `claude` credential when an external Anthropic provider is loaded. const credentials = [{ name: "opencode", providerId: "openai-compat" }]; + + // The umans credential is always listed (it's the model-catalog index); the + // provider itself only registers when UMANS_API_KEY is set, so listCatalog + // gracefully skips it when the provider is absent. + if (process.env.UMANS_API_KEY) { + credentials.push({ name: "umans", providerId: "umans" }); + logger.info(`Registered credential "umans" → umans provider`); + } const hasAnthropic = externalExtensions.some((e) => e.manifest.contributes?.providers?.includes("anthropic"), ); diff --git a/packages/openai-stream/package.json b/packages/openai-stream/package.json new file mode 100644 index 0000000..5b35ff7 --- /dev/null +++ b/packages/openai-stream/package.json @@ -0,0 +1,13 @@ +{ + "name": "@dispatch/openai-stream", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/trace-replay": "workspace:*", + "@dispatch/wire": "workspace:*" + } +} diff --git a/packages/provider-openai-compat/src/__fixtures__/flash-text-turn.json b/packages/openai-stream/src/__fixtures__/flash-text-turn.json index d7e71cb..d7e71cb 100644 --- a/packages/provider-openai-compat/src/__fixtures__/flash-text-turn.json +++ b/packages/openai-stream/src/__fixtures__/flash-text-turn.json diff --git a/packages/provider-openai-compat/src/__fixtures__/tool-call-turn.json b/packages/openai-stream/src/__fixtures__/tool-call-turn.json index 48bdb8d..48bdb8d 100644 --- a/packages/provider-openai-compat/src/__fixtures__/tool-call-turn.json +++ b/packages/openai-stream/src/__fixtures__/tool-call-turn.json diff --git a/packages/provider-openai-compat/src/convert-messages.test.ts b/packages/openai-stream/src/convert-messages.test.ts index 51513ea..51513ea 100644 --- a/packages/provider-openai-compat/src/convert-messages.test.ts +++ b/packages/openai-stream/src/convert-messages.test.ts diff --git a/packages/provider-openai-compat/src/convert-messages.ts b/packages/openai-stream/src/convert-messages.ts index 786a70d..786a70d 100644 --- a/packages/provider-openai-compat/src/convert-messages.ts +++ b/packages/openai-stream/src/convert-messages.ts diff --git a/packages/provider-openai-compat/src/convert-tools.test.ts b/packages/openai-stream/src/convert-tools.test.ts index d739652..d739652 100644 --- a/packages/provider-openai-compat/src/convert-tools.test.ts +++ b/packages/openai-stream/src/convert-tools.test.ts diff --git a/packages/provider-openai-compat/src/convert-tools.ts b/packages/openai-stream/src/convert-tools.ts index 65416bb..65416bb 100644 --- a/packages/provider-openai-compat/src/convert-tools.ts +++ b/packages/openai-stream/src/convert-tools.ts diff --git a/packages/openai-stream/src/index.ts b/packages/openai-stream/src/index.ts new file mode 100644 index 0000000..bd2f673 --- /dev/null +++ b/packages/openai-stream/src/index.ts @@ -0,0 +1,8 @@ +export type { OpenAIMessage, OpenAIToolCall } from "./convert-messages.js"; +export { convertMessages } from "./convert-messages.js"; +export type { OpenAITool } from "./convert-tools.js"; +export { convertTools } from "./convert-tools.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/openai-stream/src/listModels.test.ts index 97badaa..63c95fc 100644 --- a/packages/provider-openai-compat/src/listModels.test.ts +++ b/packages/openai-stream/src/listModels.test.ts @@ -13,6 +13,7 @@ function makeProvider(fetchFn: FetchLike, apiKey = "sk-test-1234567890abcdef"): return createOpenAICompatProvider({ credentials: creds, model: "test-model", + id: "openai-compat", fetchFn, }); } diff --git a/packages/provider-openai-compat/src/listModels.ts b/packages/openai-stream/src/listModels.ts index d253ebe..3f783f0 100644 --- a/packages/provider-openai-compat/src/listModels.ts +++ b/packages/openai-stream/src/listModels.ts @@ -2,12 +2,13 @@ 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). + * Generic OpenAI-compatible model-list fetch + mapping. Lives in this library + * (`@dispatch/openai-stream`) so any OpenAI-compatible provider extension can + * reuse it without cross-extension code import (isolation-over-DRY: coupling + * is via this typed library surface, not a sibling's internals). + * + * A provider extension supplies its own `id` (used in error labels) via + * `createOpenAICompatProvider({ id })`. */ interface OpenAIModelEntry { diff --git a/packages/provider-openai-compat/src/parse-sse.test.ts b/packages/openai-stream/src/parse-sse.test.ts index 1910833..1910833 100644 --- a/packages/provider-openai-compat/src/parse-sse.test.ts +++ b/packages/openai-stream/src/parse-sse.test.ts diff --git a/packages/provider-openai-compat/src/parse-sse.ts b/packages/openai-stream/src/parse-sse.ts index cfeb5b0..cfeb5b0 100644 --- a/packages/provider-openai-compat/src/parse-sse.ts +++ b/packages/openai-stream/src/parse-sse.ts diff --git a/packages/openai-stream/src/provider.test.ts b/packages/openai-stream/src/provider.test.ts new file mode 100644 index 0000000..7b2d938 --- /dev/null +++ b/packages/openai-stream/src/provider.test.ts @@ -0,0 +1,153 @@ +import type { ApiKeyCredentials, ChatMessage, ProviderStreamOptions } from "@dispatch/kernel"; +import type { FetchLike } from "@dispatch/trace-replay"; +import { describe, expect, it, vi } from "vitest"; +import { createOpenAICompatProvider } from "./provider.js"; + +function makeCreds(): ApiKeyCredentials { + return { + type: "api-key", + apiKey: "sk-test-1234567890abcdef", + baseURL: "https://api.example.com/v1", + }; +} + +function makeMessages(): readonly ChatMessage[] { + return [{ role: "user", chunks: [{ type: "text", text: "Hello" }] }]; +} + +function sseBody(...lines: string[]): ReadableStream<Uint8Array> { + const encoder = new TextEncoder(); + const chunks = lines.map((l) => encoder.encode(`${l}\n`)); + let index = 0; + return new ReadableStream<Uint8Array>({ + pull(controller) { + if (index < chunks.length) { + const chunk = chunks[index]; + if (chunk === undefined) throw new Error("empty chunk"); + controller.enqueue(chunk); + index++; + } else { + controller.close(); + } + }, + }); +} + +function okSseResponse(): Response { + return new Response( + sseBody( + 'data: {"id":"cmpl-1","choices":[{"delta":{"content":"Hi"},"index":0}]}', + 'data: {"id":"cmpl-1","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ); +} + +async function collectEvents(iter: AsyncIterable<unknown>): Promise<unknown[]> { + const events: unknown[] = []; + for await (const event of iter) { + events.push(event); + } + return events; +} + +describe("createOpenAICompatProvider stamps the given id on the ProviderContract + listModels", () => { + it("stamps opts.id on ProviderContract.id", () => { + const provider = createOpenAICompatProvider({ + credentials: makeCreds(), + model: "test-model", + id: "my-custom-id", + }); + expect(provider.id).toBe("my-custom-id"); + }); + + it("uses opts.id in listModels error labels (was hardcoded 'openai-compat')", async () => { + const fetchFn = vi.fn( + () => + new Response("Unauthorized", { + status: 401, + headers: { "Content-Type": "text/plain" }, + }) as unknown as ReturnType<FetchLike>, + ); + const provider = createOpenAICompatProvider({ + credentials: makeCreds(), + model: "test-model", + id: "my-custom-id", + fetchFn, + }); + const listModels = provider.listModels; + if (!listModels) throw new Error("listModels not defined"); + + await expect(listModels()).rejects.toThrow("listModels[my-custom-id]: HTTP 401 — Unauthorized"); + }); +}); + +describe("transformBody", () => { + it("transformBody merges its returned fields into the request body", async () => { + let capturedInit: RequestInit | undefined; + const fetchFn = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + capturedInit = init; + return okSseResponse(); + }) as unknown as FetchLike; + + let receivedBody: Record<string, unknown> | undefined; + let receivedOpts: ProviderStreamOptions | undefined; + const provider = createOpenAICompatProvider({ + credentials: makeCreds(), + model: "test-model", + id: "umans", + fetchFn, + transformBody: (body, opts) => { + receivedBody = body; + receivedOpts = opts; + return { reasoning_effort: "high" }; + }, + }); + + await collectEvents(provider.stream(makeMessages(), [], { temperature: 0.5 })); + + // The hook was called with the body built so far + the stream opts. + expect(receivedBody).toBeDefined(); + expect(receivedOpts?.temperature).toBe(0.5); + expect(receivedBody?.model).toBe("test-model"); + + // The captured wire body carries the merged field. + expect(capturedInit?.body).toBeTypeOf("string"); + const wireBody = JSON.parse(capturedInit?.body as string) as Record<string, unknown>; + expect(wireBody.reasoning_effort).toBe("high"); + expect(wireBody.model).toBe("test-model"); + expect(wireBody.stream).toBe(true); + expect(wireBody.temperature).toBe(0.5); + }); + + it("transformBody absent → body byte-identical to before (regression)", async () => { + let capturedInit: RequestInit | undefined; + const fetchFn = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + capturedInit = init; + return okSseResponse(); + }) as unknown as FetchLike; + + const provider = createOpenAICompatProvider({ + credentials: makeCreds(), + model: "test-model", + id: "openai-compat", + fetchFn, + // No transformBody — default behavior. + }); + + await collectEvents(provider.stream(makeMessages(), [], { temperature: 0.5, maxTokens: 42 })); + + expect(capturedInit?.body).toBeTypeOf("string"); + const wireBody = JSON.parse(capturedInit?.body as string) as Record<string, unknown>; + // Exact pre-refactor shape — no extra fields, no transformBody key leakage. + expect(wireBody).toEqual({ + model: "test-model", + messages: [{ role: "user", content: "Hello" }], + stream: true, + temperature: 0.5, + max_tokens: 42, + }); + expect("reasoning_effort" in wireBody).toBe(false); + }); +}); diff --git a/packages/provider-openai-compat/src/provider.ts b/packages/openai-stream/src/provider.ts index 19c29a1..c13d60e 100644 --- a/packages/provider-openai-compat/src/provider.ts +++ b/packages/openai-stream/src/provider.ts @@ -11,38 +11,52 @@ 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). + * Generic factory for an OpenAI-compatible provider. A provider extension + * supplies its own `id` (stamped on the ProviderContract + used in listModels + * error labels) and an optional `transformBody` hook to add provider-specific + * body fields (e.g. `reasoning_effort`) before the request is sent. The library + * names no concrete feature — those knobs belong to the extension layer. */ export interface CreateOpenAICompatProviderOpts { readonly credentials: ApiKeyCredentials; readonly model: string; + /** Provider id (was hardcoded "openai-compat"). Stamped on the ProviderContract.id + * + used in listModels error labels. */ + readonly id: string; /** * Internal injectable fetch — used by tests and replay mode. * When absent, falls back to globalThis.fetch (production default). */ readonly fetchFn?: FetchLike; + /** + * Optional hook a provider extension uses to add provider-specific body fields (e.g. + * `reasoning_effort`) before the request is sent. Receives the body built so far + + * the ProviderStreamOptions; returns ADDITIONAL fields to merge (or the full body). + * Default (absent): no extra fields. Generic — the library names no feature. + */ + readonly transformBody?: ( + body: Record<string, unknown>, + opts: ProviderStreamOptions, + ) => Record<string, unknown>; } export function createOpenAICompatProvider(opts: CreateOpenAICompatProviderOpts): ProviderContract { const baseURL = opts.credentials.baseURL ?? "https://opencode.ai/zen/go/v1"; const apiKey = opts.credentials.apiKey; const fetchFn = opts.fetchFn; + const transformBody = opts.transformBody; const streamConfig = { baseURL, apiKey, model: opts.model, ...(fetchFn !== undefined ? { fetchFn } : {}), + ...(transformBody !== undefined ? { transformBody } : {}), }; return { - id: "openai-compat", + id: opts.id, stream: ( messages: readonly ChatMessage[], tools: readonly ToolContract[], @@ -52,7 +66,7 @@ export function createOpenAICompatProvider(opts: CreateOpenAICompatProviderOpts) fetchModels({ baseURL, apiKey, - providerId: "openai-compat", + providerId: opts.id, ...(fetchFn !== undefined ? { fetchFn } : {}), }), }; diff --git a/packages/provider-openai-compat/src/stream.test.ts b/packages/openai-stream/src/stream.test.ts index 0650153..0650153 100644 --- a/packages/provider-openai-compat/src/stream.test.ts +++ b/packages/openai-stream/src/stream.test.ts diff --git a/packages/provider-openai-compat/src/stream.ts b/packages/openai-stream/src/stream.ts index b60efc1..2916432 100644 --- a/packages/provider-openai-compat/src/stream.ts +++ b/packages/openai-stream/src/stream.ts @@ -18,6 +18,19 @@ export interface StreamConfig { * When absent, falls back to globalThis.fetch (production default). */ readonly fetchFn?: FetchLike; + /** + * Optional hook a provider extension uses to add provider-specific body + * fields (e.g. `reasoning_effort`) before the request is sent. Receives the + * body built so far + the ProviderStreamOptions; returns ADDITIONAL fields + * to merge into the body (or a full body). Generic — the library names no + * feature. Applied AFTER building `body` and BEFORE `JSON.stringify`, so + * the verbatim post-transform bytes are what hit the wire (and what the + * provider.request span captures). Default (absent): no extra fields. + */ + readonly transformBody?: ( + body: Record<string, unknown>, + opts: ProviderStreamOptions, + ) => Record<string, unknown>; } /** @@ -68,6 +81,11 @@ export async function* streamChat( body.max_tokens = opts.maxTokens; } + if (config.transformBody) { + const extra = config.transformBody(body, opts ?? {}); + Object.assign(body, extra); + } + const url = `${config.baseURL}/chat/completions`; const bodyString = JSON.stringify(body); diff --git a/packages/openai-stream/tsconfig.json b/packages/openai-stream/tsconfig.json new file mode 100644 index 0000000..39be10e --- /dev/null +++ b/packages/openai-stream/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }, { "path": "../trace-replay" }, { "path": "../wire" }] +} diff --git a/packages/provider-openai-compat/package.json b/packages/provider-openai-compat/package.json index 36db8a5..465df0c 100644 --- a/packages/provider-openai-compat/package.json +++ b/packages/provider-openai-compat/package.json @@ -7,6 +7,7 @@ "types": "dist/index.d.ts", "dependencies": { "@dispatch/kernel": "workspace:*", + "@dispatch/openai-stream": "workspace:*", "@dispatch/trace-replay": "workspace:*" } } diff --git a/packages/provider-openai-compat/src/extension.ts b/packages/provider-openai-compat/src/extension.ts index 4a580d3..042a807 100644 --- a/packages/provider-openai-compat/src/extension.ts +++ b/packages/provider-openai-compat/src/extension.ts @@ -1,5 +1,5 @@ import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; -import { createOpenAICompatProvider } from "./provider.js"; +import { createOpenAICompatProvider } from "@dispatch/openai-stream"; export const manifest: Manifest = { id: "provider-openai-compat", @@ -39,7 +39,11 @@ export async function activate(host: HostAPI): Promise<void> { const model = host.config.get<string>("provider.openai-compat.model") ?? "deepseek-v4-flash"; - const provider = createOpenAICompatProvider({ credentials: creds, model }); + const provider = createOpenAICompatProvider({ + credentials: creds, + model, + id: "openai-compat", + }); host.defineProvider(provider); host.logger.info(`provider-openai-compat: registered (model=${model})`); } diff --git a/packages/provider-openai-compat/src/index.ts b/packages/provider-openai-compat/src/index.ts index 3498a9d..78e30bb 100644 --- a/packages/provider-openai-compat/src/index.ts +++ b/packages/provider-openai-compat/src/index.ts @@ -1,9 +1,14 @@ -export type { OpenAIMessage, OpenAIToolCall } from "./convert-messages.js"; -export { convertMessages } from "./convert-messages.js"; -export type { OpenAITool } from "./convert-tools.js"; -export { convertTools } from "./convert-tools.js"; +export type { + CreateOpenAICompatProviderOpts, + OpenAIMessage, + OpenAITool, + OpenAIToolCall, +} from "@dispatch/openai-stream"; +export { + convertMessages, + convertTools, + createOpenAICompatProvider, + parseModelList, + parseSSELines, +} from "@dispatch/openai-stream"; 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/tsconfig.json b/packages/provider-openai-compat/tsconfig.json index c5997ed..9cc0414 100644 --- a/packages/provider-openai-compat/tsconfig.json +++ b/packages/provider-openai-compat/tsconfig.json @@ -2,5 +2,9 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }, { "path": "../trace-replay" }] + "references": [ + { "path": "../kernel" }, + { "path": "../openai-stream" }, + { "path": "../trace-replay" } + ] } diff --git a/packages/provider-umans/package.json b/packages/provider-umans/package.json new file mode 100644 index 0000000..ca09e06 --- /dev/null +++ b/packages/provider-umans/package.json @@ -0,0 +1,12 @@ +{ + "name": "@dispatch/provider-umans", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/openai-stream": "workspace:*" + } +} diff --git a/packages/provider-umans/src/extension.test.ts b/packages/provider-umans/src/extension.test.ts new file mode 100644 index 0000000..59efddc --- /dev/null +++ b/packages/provider-umans/src/extension.test.ts @@ -0,0 +1,52 @@ +import type { HostAPI } from "@dispatch/kernel"; +import { describe, expect, it, vi } from "vitest"; +import { activate, manifest } from "./extension.js"; +import type { EnvSource } from "./resolver.js"; + +function makeFakeHost(overrides: { configGet?: (key: string) => unknown }): { + host: HostAPI; + defineProvider: ReturnType<typeof vi.fn>; + warn: ReturnType<typeof vi.fn>; + info: ReturnType<typeof vi.fn>; +} { + const defineProvider = vi.fn(); + const warn = vi.fn(); + const info = vi.fn(); + const host = { + defineProvider, + config: { get: overrides.configGet ?? (() => undefined) }, + logger: { debug: vi.fn(), info, warn, error: vi.fn() }, + } as unknown as HostAPI; + return { host, defineProvider, warn, info }; +} + +describe("provider-umans activation", () => { + it('activate registers the "umans" provider when UMANS_API_KEY is set (defineProvider called with id "umans")', async () => { + const { host, defineProvider } = makeFakeHost({}); + const env: EnvSource = { UMANS_API_KEY: "sk-test" }; + + await activate(host, env); + + expect(defineProvider).toHaveBeenCalledTimes(1); + expect(defineProvider.mock.calls[0]?.[0]?.id).toBe("umans"); + }); + + it("activate does NOT register + warns when UMANS_API_KEY is unset", async () => { + const { host, defineProvider, warn } = makeFakeHost({}); + const env: EnvSource = {}; + + await activate(host, env); + + expect(defineProvider).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith("provider-umans: no UMANS_API_KEY. Provider not registered."); + }); + + it("declares no dependsOn (self-contained, reads env directly)", () => { + expect(manifest.dependsOn).toBeUndefined(); + }); + + it("declares the umans provider contribution + network capability", () => { + expect(manifest.contributes?.providers).toEqual(["umans"]); + expect(manifest.capabilities?.network).toBe(true); + }); +}); diff --git a/packages/provider-umans/src/extension.ts b/packages/provider-umans/src/extension.ts new file mode 100644 index 0000000..74f8481 --- /dev/null +++ b/packages/provider-umans/src/extension.ts @@ -0,0 +1,55 @@ +import type { ApiKeyCredentials, Extension, HostAPI, Manifest } from "@dispatch/kernel"; +import { createOpenAICompatProvider } from "@dispatch/openai-stream"; +import { transformBody } from "./reasoning.js"; +import { type EnvSource, resolveUmansConfig } from "./resolver.js"; + +export const manifest: Manifest = { + id: "provider-umans", + name: "Umans AI Coding Plan", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + capabilities: { network: true }, + contributes: { providers: ["umans"] }, +}; + +/** + * Activate the Umans provider. Reads `UMANS_API_KEY` / `UMANS_BASE_URL` / + * `UMANS_MODEL` from the environment (the imperative shell — at the edge) and + * `provider.umans.model` from host config, resolves the config via the pure + * `resolveUmansConfig`, then builds + registers the `"umans"` provider through + * `@dispatch/openai-stream`'s `createOpenAICompatProvider`. + * + * `env` defaults to `process.env` and is a parameter only so tests can inject a + * fake environment (faking the outermost edge) without mutating the real one. + * No `UMANS_API_KEY` is a config state, not an error — warn + skip registration. + */ +export async function activate(host: HostAPI, env: EnvSource = process.env): Promise<void> { + const configModel = host.config.get<string>("provider.umans.model"); + const cfg = resolveUmansConfig(env, configModel); + if (!cfg) { + host.logger.warn("provider-umans: no UMANS_API_KEY. Provider not registered."); + return; + } + + const credentials: ApiKeyCredentials = { + type: "api-key", + apiKey: cfg.apiKey, + baseURL: cfg.baseURL, + }; + + const provider = createOpenAICompatProvider({ + credentials, + model: cfg.model, + id: "umans", + transformBody, + }); + host.defineProvider(provider); + host.logger.info(`provider-umans: registered (model=${cfg.model})`); +} + +export const extension: Extension = { + manifest, + activate, +}; diff --git a/packages/provider-umans/src/index.ts b/packages/provider-umans/src/index.ts new file mode 100644 index 0000000..d4d143b --- /dev/null +++ b/packages/provider-umans/src/index.ts @@ -0,0 +1,8 @@ +export { activate, extension, manifest } from "./extension.js"; +export { mapReasoningEffort, transformBody } from "./reasoning.js"; +export { + type EnvSource, + resolveUmansConfig, + toUmansCredentials, + type UmansConfig, +} from "./resolver.js"; diff --git a/packages/provider-umans/src/reasoning.test.ts b/packages/provider-umans/src/reasoning.test.ts new file mode 100644 index 0000000..2e57e25 --- /dev/null +++ b/packages/provider-umans/src/reasoning.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { mapReasoningEffort, transformBody } from "./reasoning.js"; + +describe("mapReasoningEffort", () => { + it('mapReasoningEffort: low → "low", medium → "medium", high → "high", xhigh → "high", max → "high"', () => { + expect(mapReasoningEffort("low")).toBe("low"); + expect(mapReasoningEffort("medium")).toBe("medium"); + expect(mapReasoningEffort("high")).toBe("high"); + expect(mapReasoningEffort("xhigh")).toBe("high"); + expect(mapReasoningEffort("max")).toBe("high"); + }); + + it("mapReasoningEffort: undefined → undefined (no field)", () => { + expect(mapReasoningEffort(undefined)).toBe(undefined); + }); +}); + +describe("transformBody", () => { + it("transformBody adds reasoning_effort when opts.reasoningEffort is set", () => { + const result = transformBody({}, { reasoningEffort: "high" }); + expect(result).toEqual({ reasoning_effort: "high" }); + }); + + it("transformBody adds nothing when opts.reasoningEffort is absent (byte-stable)", () => { + const result = transformBody({}, {}); + expect(result).toEqual({}); + }); +}); diff --git a/packages/provider-umans/src/reasoning.ts b/packages/provider-umans/src/reasoning.ts new file mode 100644 index 0000000..9015848 --- /dev/null +++ b/packages/provider-umans/src/reasoning.ts @@ -0,0 +1,36 @@ +import type { ProviderStreamOptions, ReasoningEffort } from "@dispatch/kernel"; + +/** + * Map a resolved `ReasoningEffort` to Umans' `reasoning_effort` wire value. + * + * Umans' OpenAI route accepts `"none"|"low"|"medium"|"high"`; Dispatch's + * `ReasoningEffort` adds `"xhigh"|"max"`, which Umans caps to `"high"`. An + * absent effort (`undefined`) maps to `undefined` so the caller emits no + * `reasoning_effort` field at all — byte-stable when the caller has no + * preference. + * + * Pure: the `transformBody` decision, factored out for direct unit testing. + */ +export function mapReasoningEffort( + effort: ReasoningEffort | undefined, +): "low" | "medium" | "high" | undefined { + if (effort === undefined) return undefined; + if (effort === "xhigh" || effort === "max") return "high"; + return effort; +} + +/** + * Provider-specific body transform handed to `@dispatch/openai-stream`'s + * `createOpenAICompatProvider`. Returns the extra fields the library merges + * into the chat-completions body before send. Adds `reasoning_effort` only when + * a resolved effort is present; returns `{}` (no fields) otherwise — + * byte-stable for calls with no reasoning preference. + */ +export function transformBody( + _body: Record<string, unknown>, + opts: ProviderStreamOptions, +): Record<string, unknown> { + const mapped = mapReasoningEffort(opts.reasoningEffort); + if (mapped === undefined) return {}; + return { reasoning_effort: mapped }; +} diff --git a/packages/provider-umans/src/resolver.test.ts b/packages/provider-umans/src/resolver.test.ts new file mode 100644 index 0000000..47d436e --- /dev/null +++ b/packages/provider-umans/src/resolver.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { resolveUmansConfig } from "./resolver.js"; + +describe("resolveUmansConfig", () => { + it("activate uses UMANS_BASE_URL override when set (default otherwise)", () => { + const override = resolveUmansConfig( + { UMANS_API_KEY: "sk-test", UMANS_BASE_URL: "https://custom.example.com/v1" }, + undefined, + ); + expect(override?.baseURL).toBe("https://custom.example.com/v1"); + + const fallback = resolveUmansConfig({ UMANS_API_KEY: "sk-test" }, undefined); + expect(fallback?.baseURL).toBe("https://api.code.umans.ai/v1"); + }); + + it('activate uses config provider.umans.model → UMANS_MODEL → "umans-coder" resolution', () => { + // config wins over env + default + const fromConfig = resolveUmansConfig( + { UMANS_API_KEY: "sk-test", UMANS_MODEL: "env-model" }, + "config-model", + ); + expect(fromConfig?.model).toBe("config-model"); + + // env wins when config is absent + const fromEnv = resolveUmansConfig( + { UMANS_API_KEY: "sk-test", UMANS_MODEL: "env-model" }, + undefined, + ); + expect(fromEnv?.model).toBe("env-model"); + + // default when neither is set + const fromDefault = resolveUmansConfig({ UMANS_API_KEY: "sk-test" }, undefined); + expect(fromDefault?.model).toBe("umans-coder"); + }); + + it("returns null when UMANS_API_KEY is unset", () => { + expect(resolveUmansConfig({}, undefined)).toBeNull(); + }); + + it("returns null when UMANS_API_KEY is empty string", () => { + expect(resolveUmansConfig({ UMANS_API_KEY: "" }, undefined)).toBeNull(); + }); +}); diff --git a/packages/provider-umans/src/resolver.ts b/packages/provider-umans/src/resolver.ts new file mode 100644 index 0000000..416a281 --- /dev/null +++ b/packages/provider-umans/src/resolver.ts @@ -0,0 +1,51 @@ +import type { ApiKeyCredentials } from "@dispatch/kernel"; + +const DEFAULT_BASE_URL = "https://api.code.umans.ai/v1"; +const DEFAULT_MODEL = "umans-coder"; + +/** + * Resolved Umans provider config — the API key plus the overridable base URL + * and model that `activate` threads into `createOpenAICompatProvider`. + */ +export interface UmansConfig { + readonly apiKey: string; + readonly baseURL: string; + readonly model: string; +} + +/** + * Environment source for `resolveUmansConfig` — `process.env` (or a fake for + * tests). Matches the canonical `Readonly<Record<string, string | undefined>>` + * env view used across the codebase. + */ +export type EnvSource = Readonly<Record<string, string | undefined>>; + +/** + * Resolve Umans provider config from the environment + the host config. + * + * Precedence (mirrors `provider-openai-compat`): + * - `apiKey`: `UMANS_API_KEY` — unset/empty → `null` (caller warns + skips). + * - `baseURL`: `UMANS_BASE_URL` → `https://api.code.umans.ai/v1`. + * - `model`: `provider.umans.model` (host config) → `UMANS_MODEL` → `umans-coder`. + * + * Pure: the decision logic `activate` delegates to, factored out for direct + * unit testing with zero mocks. + */ +export function resolveUmansConfig( + env: EnvSource, + configModel: string | undefined, +): UmansConfig | null { + const apiKey = env.UMANS_API_KEY; + if (!apiKey) return null; + const baseURL = env.UMANS_BASE_URL ?? DEFAULT_BASE_URL; + const model = configModel ?? env.UMANS_MODEL ?? DEFAULT_MODEL; + return { apiKey, baseURL, model }; +} + +/** + * Build the `ApiKeyCredentials` the `@dispatch/openai-stream` library expects + * — `baseURL` on the credential so it is overridable per-credential. + */ +export function toUmansCredentials(cfg: UmansConfig): ApiKeyCredentials { + return { type: "api-key", apiKey: cfg.apiKey, baseURL: cfg.baseURL }; +} diff --git a/packages/provider-umans/tsconfig.json b/packages/provider-umans/tsconfig.json new file mode 100644 index 0000000..f450b9a --- /dev/null +++ b/packages/provider-umans/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }, { "path": "../openai-stream" }] +} @@ -5,7 +5,7 @@ > Keep this lean and current; do not let it re-accrete a step-by-step changelog. ## Status (current) -`tsc -b` EXIT 0 · biome clean · **1043 vitest + transport bun green**. +`tsc -b` EXIT 0 · biome clean · **1059 vitest + 199 transport bun green**. Built and verified live (full-fidelity: every feature is a manifest-loaded extension through the host): @@ -424,6 +424,38 @@ additive `steering` `AgentEvent`; queue state via the surface (NOT the chat stre `../dispatch-web`): surface (`rendererId:"message-queue"`), `chat.queue` WS op, `steering` event, HTTP `POST /queue`, auto-start-when-idle, carry semantics, version bumps. +## Umans AI Coding Plan provider (DONE) +User-gated calls: a new **`provider-umans`** standard extension wrapping the Umans +OpenAI-compatible backend (`https://api.code.umans.ai/v1`). Built via the **full-refactor +path**: first extract a generic `@dispatch/openai-stream` library from +`provider-openai-compat`, then build `provider-umans` on top. Self-contained (reads +`UMANS_API_KEY` from env directly — no `auth-apikey` dep). +- **Wave 1 — `@dispatch/openai-stream` lib (NEW package):** extracted the generic OpenAI + functions (convert-messages, convert-tools, parse-sse, listModels, stream, provider) + from `provider-openai-compat` into a pure library package. `createOpenAICompatProvider` + parameterized: `id: string` (was hardcoded `"openai-compat"`) + `transformBody?: (body, + opts) => Record<string,unknown>` hook (for provider-specific body fields). Refactored + `provider-openai-compat` to import from the lib (thin extension.ts, backward-compat + re-exports, manifest unchanged, byte-identical behavior). Full tsc EXIT 0, 66 vitest, + biome clean. Report: `reports/provider-umans-wave1-openai-stream.md`. +- **Wave 2 — `provider-umans` (NEW ext):** imports `createOpenAICompatProvider` from the + lib; registers provider id `"umans"`; `transformBody` maps Dispatch `reasoningEffort` + (`low|medium|high|xhigh|max`) → Umans `reasoning_effort` (`none|low|medium|high`, + capping `xhigh`/`max`→`high`); dynamic `listModels` (GET /v1/models); default model + `umans-coder` (env `UMANS_MODEL` or config `provider.umans.model`); baseURL env + `UMANS_BASE_URL`; absent key → warn + skip registration (graceful). Pure core: + `mapReasoningEffort` + `resolveUmansConfig` (factored out for direct unit testing). + 12 tests. Report: `reports/provider-umans.md`. +- **Wave 3 — host-bin wiring:** registered `provider-umans` in `CORE_EXTENSIONS` + added + `@dispatch/provider-umans` dep + root tsconfig ref. No credential-store entry needed + (self-contained — reads env directly, doesn't go through `auth-apikey`). 28 host-bin + tests. +- Verified: full-graph `tsc -b` EXIT 0, biome clean (293 files), **1059 vitest** pass. + **Boot smoke:** without `UMANS_API_KEY` → `"provider-umans: no UMANS_API_KEY. Provider + not registered."` (graceful skip); with `UMANS_API_KEY=sk-test` → `"provider-umans: + registered (model=umans-coder)"`. +- [ ] Live-verify against the real Umans API (not yet exercised end-to-end). + ## Open items - **Context window LIMIT (deferred, sibling of context size):** expose the selected model's max context-window token limit so the FE can render `contextSize / limit` (e.g. `1286 / 200000`). diff --git a/tsconfig.json b/tsconfig.json index 66982e4..b227e92 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,8 @@ { "path": "./packages/storage-sqlite" }, { "path": "./packages/auth-apikey" }, { "path": "./packages/provider-openai-compat" }, + { "path": "./packages/openai-stream" }, + { "path": "./packages/provider-umans" }, { "path": "./packages/credential-store" }, { "path": "./packages/conversation-store" }, { "path": "./packages/throughput-store" }, |
