diff --git a/README.md b/README.md index 474a73a..50ed91c 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,18 @@ Then reload pi: /reload ``` +### Oh My Pi + +```sh +omp plugin install pi-commandcode-provider +``` + +Then restart OMP or run: + +```txt +/reload +``` + ## Setup Set your Command Code API key using one of these methods: @@ -83,7 +95,7 @@ The official Command Code CLI auth shape is also supported: } ``` -Or use pi's auth file at `~/.pi/agent/auth.json`: +Or use a pi/OMP auth file at `~/.pi/agent/auth.json` or `~/.omp/agent/auth.json`: ```json { @@ -99,12 +111,21 @@ After installing and setting your API key, select a Command Code model in pi: /model deepseek/deepseek-v4-flash ``` -Any query will then use the Command Code API. You can list available models within pi: +Any query will then use the Command Code API. You can list available models: -```txt -/models +```sh +pi -e index.ts --list-models # or /models within pi +omp -e index.ts --list-models +``` + +In OMP, use the provider-qualified model name: + +```sh +omp -p "hello" --model commandcode/deepseek/deepseek-v4-flash ``` +OMP currently resolves `--provider commandcode --model ...` before extension providers are loaded, so prefer `--model commandcode/`. + ## Model discovery On startup, the provider fetches: diff --git a/index.ts b/index.ts index 6049d7b..1702945 100644 --- a/index.ts +++ b/index.ts @@ -12,7 +12,7 @@ * Models are fetched from Command Code's Provider API at startup. */ -import { calculateCost, createAssistantMessageEventStream } from "@mariozechner/pi-ai" +import { AssistantMessageEventStream, calculateCost } from "@mariozechner/pi-ai" import type { ExtensionAPI } from "@mariozechner/pi-coding-agent" import { COMMAND_CODE_CLI_VERSION, createStreamCommandCode, DEFAULT_API_BASE } from "./src/core.ts" @@ -23,7 +23,7 @@ const API_BASE = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE const MODELS_URL = process.env.COMMANDCODE_MODELS_URL ?? DEFAULT_MODELS_URL const streamCommandCode = createStreamCommandCode({ - createStream: createAssistantMessageEventStream, + createStream: () => new AssistantMessageEventStream(), calculateCost, apiBase: API_BASE, }) diff --git a/package.json b/package.json index 72a1cf4..b3a9b75 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "LICENSE" ], "scripts": { - "test": "npm run typecheck && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-stream.ts && node tests/test-pi-local.mjs", + "test": "npm run typecheck && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-stream.ts && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs", "typecheck": "tsc --noEmit", "format:check": "prettier --check '**/*.{ts,mjs,json,md}'", "format": "prettier --write '**/*.{ts,mjs,json,md}'", diff --git a/src/converters.ts b/src/converters.ts index a9f5242..38ca0d3 100644 --- a/src/converters.ts +++ b/src/converters.ts @@ -39,7 +39,11 @@ export function numberValue(value: unknown): number | undefined { } function defaultAuthPaths(home: string): string[] { - return [join(home, ".commandcode", "auth.json"), join(home, ".pi", "agent", "auth.json")] + return [ + join(home, ".commandcode", "auth.json"), + join(home, ".omp", "agent", "auth.json"), + join(home, ".pi", "agent", "auth.json"), + ] } function apiKeyFromCredentialRecord(value: unknown): string | undefined { @@ -284,3 +288,30 @@ export function mapFinishReason(reason: unknown): StopReason { } return "stop" } + +function promptPartToText(value: unknown, depth = 0): string { + if (depth > 10) return "" + if (typeof value === "string") return value + if (Array.isArray(value)) + return value + .map((v) => promptPartToText(v, depth + 1)) + .filter(Boolean) + .join("\n") + if (!isRecord(value)) return "" + const text = stringValue(value.text) + if (text) return text + const content = promptPartToText(value.content, depth + 1) + if (content) return content + return "" +} + +export function systemPromptToText(value: unknown): string { + if (value === undefined || value === null) return "" + if (typeof value === "string") return value + if (Array.isArray(value)) + return value + .map((v) => promptPartToText(v, 0)) + .filter(Boolean) + .join("\n\n") + return promptPartToText(value, 0) +} diff --git a/src/core.ts b/src/core.ts index 901fdb1..41a6560 100644 --- a/src/core.ts +++ b/src/core.ts @@ -18,6 +18,7 @@ import { recordOrEmpty, stringValue, toolsToJson, + systemPromptToText, } from "./converters.ts" import type { AssistantMessageEventStreamLike, @@ -131,8 +132,13 @@ export function createStreamCommandCode(deps: CoreDependencies) { const stream = deps.createStream() async function run() { + // OMP may pass the env-var name "COMMANDCODE_API_KEY" as the apiKey + // value instead of resolving it. Filter out this specific string. + const hostKey = + options?.apiKey && options.apiKey !== "COMMANDCODE_API_KEY" ? options.apiKey : undefined + const apiKey = - options?.apiKey ?? + hostKey ?? getApiKey({ env: deps.env, authPaths: deps.authPaths, @@ -149,7 +155,7 @@ export function createStreamCommandCode(deps: CoreDependencies) { usage: defaultUsage(), stopReason: "error", errorMessage: - "No Command Code API key. Run /login and select Command Code, set COMMANDCODE_API_KEY env var, or configure ~/.commandcode/auth.json or ~/.pi/agent/auth.json.", + "No Command Code API key. Run /login and select Command Code, set the COMMANDCODE_API_KEY env var, or configure ~/.commandcode/auth.json, ~/.pi/agent/auth.json or ~/.omp/agent/auth.json", timestamp: now(), } stream.push({ type: "error", reason: "error", error: msg }) @@ -355,7 +361,7 @@ export function createStreamCommandCode(deps: CoreDependencies) { model: model.id, messages: messagesToCC(context.messages), tools: toolsToJson(context.tools), - system: context.systemPrompt ?? "", + system: systemPromptToText(context.systemPrompt), max_tokens: generateMaxTokens(model, options), temperature: 0.3, stream: true, diff --git a/tests/test-omp-compat.mjs b/tests/test-omp-compat.mjs new file mode 100644 index 0000000..b591675 --- /dev/null +++ b/tests/test-omp-compat.mjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node +/** + * OMP compatibility smoke test. + * + * Uses an isolated HOME/PI_CODING_AGENT_DIR so the test does not depend on or + * mutate the user's real ~/.omp state. The Command Code API base is pointed at + * a deterministic local mock server so print mode can exercise the provider + * without touching the real API. + */ + +import assert from "node:assert/strict" +import { spawn } from "node:child_process" +import { accessSync, constants, mkdtempSync, rmSync } from "node:fs" +import { createServer } from "node:http" +import { tmpdir } from "node:os" +import { delimiter, dirname, join, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const PROJECT_DIR = resolve(__dirname, "..") +const EXT_PATH = resolve(PROJECT_DIR, "index.ts") +const TEST_MODEL = "deepseek/deepseek-v4-flash" + +function findOmpBinary() { + if (process.env.OMP_BIN) return process.env.OMP_BIN + const candidates = (process.env.PATH ?? "").split(delimiter).map((entry) => resolve(entry, "omp")) + for (const candidate of candidates) { + try { + accessSync(candidate, constants.X_OK) + return candidate + } catch { + // Try next PATH entry. + } + } + return undefined +} + +const OMP_BIN = findOmpBinary() +if (!OMP_BIN) { + console.log("[omp-compat] SKIP - omp is not on PATH") + process.exit(0) +} + +const tempHome = mkdtempSync(join(tmpdir(), "omp-cc-home-")) +let requestCount = 0 +let modelListRequestCount = 0 +let lastRequestBody +let lastRequestHeaders = {} + +const server = createServer((req, res) => { + if (req.method === "GET" && req.url === "/provider/v1/models") { + modelListRequestCount += 1 + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }) + res.end( + JSON.stringify({ + object: "list", + data: [ + { + id: TEST_MODEL, + object: "model", + created: 1779824324, + owned_by: "command-code", + name: "DeepSeek V4 Flash", + context_length: 1_000_000, + }, + { + id: "Qwen/Qwen3.7-Max", + object: "model", + created: 1779824324, + owned_by: "command-code", + name: "Qwen 3.7 Max", + context_length: 1_000_000, + }, + ], + }), + ) + return + } + + if (req.method !== "POST" || req.url !== "/alpha/generate") { + res.writeHead(404) + res.end("Not found") + return + } + + requestCount += 1 + lastRequestHeaders = Object.fromEntries( + Object.entries(req.headers).map(([key, value]) => [ + key, + Array.isArray(value) ? value.join(", ") : (value ?? ""), + ]), + ) + + let body = "" + req.on("data", (chunk) => { + body += chunk.toString("utf-8") + }) + req.on("end", () => { + try { + lastRequestBody = JSON.parse(body) + } catch { + lastRequestBody = undefined + } + + res.writeHead(200, { + "Content-Type": "text/plain; charset=utf-8", + "Transfer-Encoding": "chunked", + }) + res.write(`${JSON.stringify({ type: "text-delta", text: "mock-omp-ok" })}\n`) + res.write( + `${JSON.stringify({ type: "finish", finishReason: "stop", totalUsage: { inputTokens: 1, outputTokens: 1 } })}\n`, + ) + res.end() + }) +}) + +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) +const address = server.address() +const port = typeof address === "object" && address ? address.port : 0 +const apiBase = `http://127.0.0.1:${port}` + +function runOmp(args, timeoutMs = 30_000) { + return new Promise((resolve) => { + const child = spawn(OMP_BIN, args, { + cwd: PROJECT_DIR, + env: { + ...process.env, + HOME: tempHome, + USERPROFILE: tempHome, + PI_CODING_AGENT_DIR: join(tempHome, ".omp", "agent"), + COMMANDCODE_API_KEY: "mock-key", + COMMANDCODE_API_BASE: apiBase, + COMMANDCODE_MODELS_URL: `${apiBase}/provider/v1/models`, + }, + stdio: ["ignore", "pipe", "pipe"], + }) + let stdout = "" + let stderr = "" + const timer = setTimeout(() => { + child.kill() + resolve({ + code: -1, + stdout, + stderr: `${stderr}\nTIMEOUT after ${timeoutMs}ms`, + }) + }, timeoutMs) + child.stdout.on("data", (chunk) => { + stdout += chunk.toString("utf-8") + }) + child.stderr.on("data", (chunk) => { + stderr += chunk.toString("utf-8") + }) + child.on("close", (code) => { + clearTimeout(timer) + resolve({ code, stdout, stderr }) + }) + }) +} + +try { + console.log("[omp-compat] list models through real extension") + modelListRequestCount = 0 + const result = await runOmp(["-e", EXT_PATH, "--list-models"]) + assert.equal(result.code, 0, result.stderr) + const listOutput = result.stdout || result.stderr + assert.match(listOutput, /commandcode/) + assert.match(listOutput, /deepseek\/deepseek-v4-flash/) + assert.equal(modelListRequestCount, 1) + assert.doesNotMatch(result.stdout + result.stderr, /Failed to load extension/) + + console.log("[omp-compat] print mode through real extension and mock API") + requestCount = 0 + const print = await runOmp( + ["-e", EXT_PATH, "-p", "say mock token", "--model", `commandcode/${TEST_MODEL}`], + 30_000, + ) + assert.equal(print.code, 0, print.stderr) + assert.match(print.stdout, /mock-omp-ok/) + assert.equal(requestCount, 1) + assert.equal( + lastRequestHeaders.authorization, + "Bearer mock-key", + "should send the resolved env-var value, not the literal var name", + ) + assert.equal(lastRequestBody?.params?.model, TEST_MODEL) + assert.equal(typeof lastRequestBody?.params?.system, "string") + + console.log("[omp-compat] PASS") +} finally { + await new Promise((resolve) => server.close(resolve)) + rmSync(tempHome, { recursive: true, force: true }) +} diff --git a/tests/test-pi-local.mjs b/tests/test-pi-local.mjs index 8b37560..617025a 100644 --- a/tests/test-pi-local.mjs +++ b/tests/test-pi-local.mjs @@ -136,6 +136,7 @@ function hasLivePiAuth() { return ( !!process.env.COMMANDCODE_API_KEY || existsSync(join(homedir(), ".commandcode", "auth.json")) || + existsSync(join(homedir(), ".omp", "agent", "auth.json")) || existsSync(join(homedir(), ".pi", "agent", "auth.json")) ) } @@ -294,9 +295,10 @@ try { modelListRequestCount = 0 const list = await runPi(["--no-extensions", "-e", EXT_PATH, "--list-models"], 20_000) assert.equal(list.code, 0, list.stderr) - assert.match(list.stdout, /commandcode/) - assert.match(list.stdout, /deepseek\/deepseek-v4-flash/) - assert.match(list.stdout, /Qwen\/Qwen3\.7-Max/) + const listOutput = list.stdout || list.stderr + assert.match(listOutput, /commandcode/) + assert.match(listOutput, /deepseek\/deepseek-v4-flash/) + assert.match(listOutput, /Qwen\/Qwen3\.7-Max/) assert.equal(modelListRequestCount, 1) console.log("[pi-local] print mode through real extension and mock API") diff --git a/tests/test-stream.ts b/tests/test-stream.ts index 800a972..621891d 100644 --- a/tests/test-stream.ts +++ b/tests/test-stream.ts @@ -54,6 +54,27 @@ describe("streamCommandCode — auth", () => { assert.equal(server.requestCount(), 0) }) + it("ignores the literal env-var name and falls back to env", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ + apiBase: server.baseUrl(), + env: { COMMANDCODE_API_KEY: "env-key" }, + }) + + await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "COMMANDCODE_API_KEY" }), + ) + + assert.equal( + server.lastRequestHeaders().authorization, + "Bearer env-key", + "should resolve from env, not send the literal var name as the token", + ) + }) + it("uses options.apiKey in the Authorization header", async () => { server.mockResponse({ type: "success", @@ -302,6 +323,29 @@ describe("streamCommandCode — request serialization", () => { assert.equal(objectAt(server.lastRequestBody(), ["params", "max_tokens"]), 8_192) }) + it("serializes OMP system prompt arrays as a string", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + await collectEvents( + streamCommandCode( + makeModel(), + makeContext({ + systemPrompt: ["You are a test assistant.", "Use concise answers."] as unknown as string, + }), + { apiKey: "mock-key" }, + ), + ) + + assert.equal( + objectAt(server.lastRequestBody(), ["params", "system"]), + "You are a test assistant.\n\nUse concise answers.", + ) + }) + it("runs onPayload and onResponse hooks", async () => { server.mockResponse({ type: "success",