Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
{
Expand All @@ -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-id>`. <!-- TODO: remove this note once OMP fixes provider resolution order for extension-loaded providers -->

## Model discovery

On startup, the provider fetches:
Expand Down
4 changes: 2 additions & 2 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
})
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}'",
Expand Down
33 changes: 32 additions & 1 deletion src/converters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
12 changes: 9 additions & 3 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
recordOrEmpty,
stringValue,
toolsToJson,
systemPromptToText,
} from "./converters.ts"
import type {
AssistantMessageEventStreamLike,
Expand Down Expand Up @@ -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 ??
Comment thread
patlux marked this conversation as resolved.
getApiKey({
env: deps.env,
authPaths: deps.authPaths,
Expand All @@ -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 })
Expand Down Expand Up @@ -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,
Expand Down
192 changes: 192 additions & 0 deletions tests/test-omp-compat.mjs
Original file line number Diff line number Diff line change
@@ -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 })
}
8 changes: 5 additions & 3 deletions tests/test-pi-local.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
)
}
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading