summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-21 12:09:09 +0900
committerAdam Malczewski <[email protected]>2026-06-21 12:09:09 +0900
commitd23de3254374d4d63c8e15c6ab9311c3c6f4da5b (patch)
tree6105e3a8639555b2c925d412a567bcb8caa4075f
parentba47df37f0c89bff4f0c3dd7d0bc2ef6c8062b92 (diff)
downloaddispatch-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).
-rw-r--r--README.md5
-rw-r--r--bun.lock24
-rw-r--r--packages/host-bin/package.json1
-rw-r--r--packages/host-bin/src/main.ts10
-rw-r--r--packages/openai-stream/package.json13
-rw-r--r--packages/openai-stream/src/__fixtures__/flash-text-turn.json (renamed from packages/provider-openai-compat/src/__fixtures__/flash-text-turn.json)0
-rw-r--r--packages/openai-stream/src/__fixtures__/tool-call-turn.json (renamed from packages/provider-openai-compat/src/__fixtures__/tool-call-turn.json)0
-rw-r--r--packages/openai-stream/src/convert-messages.test.ts (renamed from packages/provider-openai-compat/src/convert-messages.test.ts)0
-rw-r--r--packages/openai-stream/src/convert-messages.ts (renamed from packages/provider-openai-compat/src/convert-messages.ts)0
-rw-r--r--packages/openai-stream/src/convert-tools.test.ts (renamed from packages/provider-openai-compat/src/convert-tools.test.ts)0
-rw-r--r--packages/openai-stream/src/convert-tools.ts (renamed from packages/provider-openai-compat/src/convert-tools.ts)0
-rw-r--r--packages/openai-stream/src/index.ts8
-rw-r--r--packages/openai-stream/src/listModels.test.ts (renamed from packages/provider-openai-compat/src/listModels.test.ts)1
-rw-r--r--packages/openai-stream/src/listModels.ts (renamed from packages/provider-openai-compat/src/listModels.ts)13
-rw-r--r--packages/openai-stream/src/parse-sse.test.ts (renamed from packages/provider-openai-compat/src/parse-sse.test.ts)0
-rw-r--r--packages/openai-stream/src/parse-sse.ts (renamed from packages/provider-openai-compat/src/parse-sse.ts)0
-rw-r--r--packages/openai-stream/src/provider.test.ts153
-rw-r--r--packages/openai-stream/src/provider.ts (renamed from packages/provider-openai-compat/src/provider.ts)30
-rw-r--r--packages/openai-stream/src/stream.test.ts (renamed from packages/provider-openai-compat/src/stream.test.ts)0
-rw-r--r--packages/openai-stream/src/stream.ts (renamed from packages/provider-openai-compat/src/stream.ts)18
-rw-r--r--packages/openai-stream/tsconfig.json6
-rw-r--r--packages/provider-openai-compat/package.json1
-rw-r--r--packages/provider-openai-compat/src/extension.ts8
-rw-r--r--packages/provider-openai-compat/src/index.ts21
-rw-r--r--packages/provider-openai-compat/tsconfig.json6
-rw-r--r--packages/provider-umans/package.json12
-rw-r--r--packages/provider-umans/src/extension.test.ts52
-rw-r--r--packages/provider-umans/src/extension.ts55
-rw-r--r--packages/provider-umans/src/index.ts8
-rw-r--r--packages/provider-umans/src/reasoning.test.ts28
-rw-r--r--packages/provider-umans/src/reasoning.ts36
-rw-r--r--packages/provider-umans/src/resolver.test.ts43
-rw-r--r--packages/provider-umans/src/resolver.ts51
-rw-r--r--packages/provider-umans/tsconfig.json6
-rw-r--r--tasks.md34
-rw-r--r--tsconfig.json2
36 files changed, 619 insertions, 26 deletions
diff --git a/README.md b/README.md
index 7c01369..de95ed0 100644
--- a/README.md
+++ b/README.md
@@ -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`.)
diff --git a/bun.lock b/bun.lock
index 5716c6a..c08d7f9 100644
--- a/bun.lock
+++ b/bun.lock
@@ -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" }]
+}
diff --git a/tasks.md b/tasks.md
index 7f1a793..0efc511 100644
--- a/tasks.md
+++ b/tasks.md
@@ -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" },