Merge pull request #7 from skyscribe-yf/main

feat(stream,models): incremental reasoning streaming + live display pricing
This commit is contained in:
Patrick Wozniak
2026-05-28 22:29:12 +02:00
committed by GitHub
8 changed files with 2458 additions and 2700 deletions
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## 0.2.0 - 2026-05-27
- Stream `reasoning-delta` events incrementally instead of buffering the full thinking block until `reasoning-end`. Emits `thinking_start`, `thinking_delta`, and `thinking_end` events as they arrive so the UI can show reasoning in real time.
- Close open text blocks on `reasoning-start` and `reasoning-delta` so thinking and text never overlap in the output.
- Add live display pricing (`MODEL_COSTS`) for known Command Code models. Cost falls back to zero for models not yet in the price table until the Provider API exposes pricing directly.
- Fetch models from the Command Code Provider API at startup (inherited from upstream 0.1.1) and overlay the static cost table.
## 0.1.1 - 2026-05-26
- Align Command Code generate requests with CLI `0.27.2` headers and payload shape.
+9
View File
@@ -136,6 +136,15 @@ https://api.commandcode.ai/provider/v1/models
For tests or local mocks, override it with `COMMANDCODE_MODELS_URL`.
## Pricing
Command Code does not yet expose model pricing through its Provider API. The provider ships a static cost table (`MODEL_COSTS` in `index.ts`) for known models so that pi can display per-model pricing.
- Models present in `MODEL_COSTS` show their real per-million-token rates (including promotional deals like the DeepSeek V4 Pro 4× discount and Qwen 3.7 Max 2× discount).
- Models **not** in the table fall back to zero cost. When the Provider API adds a `cost` field, the static table can be removed.
To add or update a price, edit the `MODEL_COSTS` record in `index.ts` and update the corresponding test in `tests/test-pricing.ts`.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, PR expectations, and commit message rules.
+48 -3
View File
@@ -12,8 +12,8 @@
* Models are fetched from Command Code's Provider API at startup.
*/
import { AssistantMessageEventStream, calculateCost } from "@mariozechner/pi-ai"
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
import { AssistantMessageEventStream, calculateCost } from "@earendil-works/pi-ai"
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
import { COMMAND_CODE_CLI_VERSION, createStreamCommandCode, DEFAULT_API_BASE } from "./src/core.ts"
import { DEFAULT_MODELS_URL, fetchCommandCodeModels } from "./src/models.ts"
@@ -22,6 +22,51 @@ import { getApiKey, login, refreshToken } from "./src/oauth.ts"
const API_BASE = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE
const MODELS_URL = process.env.COMMANDCODE_MODELS_URL ?? DEFAULT_MODELS_URL
type CommandCodeModelCost = {
input: number
output: number
cacheRead: number
cacheWrite: number
}
const ZERO_MODEL_COST: CommandCodeModelCost = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
}
// The Provider API supplies the current model list. Keep known display pricing
// here until the Provider API exposes prices directly.
const MODEL_COSTS: Record<string, CommandCodeModelCost> = {
"claude-opus-4-7": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
"claude-opus-4-6": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
"claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
"claude-haiku-4-5-20251001": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
"gpt-5.5": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
"gpt-5.4": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
"gpt-5.3-codex": { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 0 },
"gpt-5.4-mini": { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
"google/gemini-3.5-flash": { input: 1.5, output: 9, cacheRead: 0.15, cacheWrite: 0 },
"google/gemini-3.1-flash-lite": { input: 0.25, output: 1.5, cacheRead: 0.03, cacheWrite: 0 },
// 4× usage deal: 75% off (permanent, no expiry)
"deepseek/deepseek-v4-pro": { input: 0.435, output: 0.87, cacheRead: 0.003625, cacheWrite: 0 },
"deepseek/deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.028, cacheWrite: 0 },
"moonshotai/Kimi-K2.6": { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 },
"moonshotai/Kimi-K2.5": { input: 0.6, output: 3, cacheRead: 0.1, cacheWrite: 0 },
"zai-org/GLM-5.1": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 },
"zai-org/GLM-5": { input: 1, output: 3.2, cacheRead: 0.2, cacheWrite: 0 },
"MiniMaxAI/MiniMax-M2.7": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 },
"MiniMaxAI/MiniMax-M2.5": { input: 0.27, output: 0.95, cacheRead: 0.03, cacheWrite: 0 },
"Qwen/Qwen3.6-Max-Preview": { input: 1.3, output: 7.8, cacheRead: 0.26, cacheWrite: 1.63 },
"Qwen/Qwen3.6-Plus": { input: 0.5, output: 3, cacheRead: 0.1, cacheWrite: 0 },
// 2× usage deal: 50% off through June 22, 2026
"Qwen/Qwen3.7-Max": { input: 1.25, output: 3.75, cacheRead: 0.25, cacheWrite: 1.56 },
"stepfun/Step-3.5-Flash": { input: 0.1, output: 0.3, cacheRead: 0.02, cacheWrite: 0 },
"xiaomi/mimo-v2.5-pro": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"xiaomi/mimo-v2.5": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
}
const streamCommandCode = createStreamCommandCode({
createStream: () => new AssistantMessageEventStream(),
calculateCost,
@@ -57,7 +102,7 @@ export default async function (pi: ExtensionAPI) {
name: model.name,
reasoning: model.reasoning,
input: ["text"] as const,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
cost: MODEL_COSTS[model.id] ?? ZERO_MODEL_COST,
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
})),
+2194 -2666
View File
File diff suppressed because it is too large Load Diff
+12 -4
View File
@@ -1,6 +1,6 @@
{
"name": "pi-commandcode-provider",
"version": "0.1.1",
"version": "0.2.0",
"description": "pi custom provider for Command Code API (commandcode.ai)",
"type": "module",
"keywords": [
@@ -28,12 +28,13 @@
"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 && node tests/test-omp-compat.mjs",
"test": "npm run typecheck && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-pricing.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}'",
"test:unit": "tsx tests/test-pure-functions.ts",
"test:models": "tsx tests/test-models.ts",
"test:pricing": "tsx tests/test-pricing.ts",
"test:oauth": "tsx tests/test-oauth.ts",
"test:abort": "tsx tests/test-abort.ts",
"test:stream": "tsx tests/test-stream.ts",
@@ -46,13 +47,20 @@
]
},
"devDependencies": {
"@mariozechner/pi-coding-agent": "0.72.0",
"@types/node": "25.6.0",
"prettier": "^3.5.0",
"tsx": "4.21.0",
"typescript": "6.0.3"
},
"dependencies": {
"@mariozechner/pi-ai": "0.72.0"
"@earendil-works/pi-ai": "0.75.5"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "^0.75.5"
},
"peerDependenciesMeta": {
"@earendil-works/pi-coding-agent": {
"optional": true
}
}
}
+38 -27
View File
@@ -178,7 +178,7 @@ export function createStreamCommandCode(deps: CoreDependencies) {
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
let textBlock: TextContent | undefined
let currentTextIdx = -1
let thinkingBlock: string[] = []
let thinkingIdx = -1
let finished = false
const abortUpstream = () => {
@@ -210,29 +210,18 @@ export function createStreamCommandCode(deps: CoreDependencies) {
currentTextIdx = -1
}
const flushThinkingBlock = () => {
if (thinkingBlock.length === 0) return
const thinkingText = thinkingBlock.join("")
thinkingBlock = []
output.content.push({ type: "thinking", thinking: thinkingText })
const idx = output.content.length - 1
stream.push({
type: "thinking_start",
contentIndex: idx,
partial: output,
})
stream.push({
type: "thinking_delta",
contentIndex: idx,
delta: thinkingText,
partial: output,
})
stream.push({
type: "thinking_end",
contentIndex: idx,
content: thinkingText,
partial: output,
})
const endThinking = () => {
if (thinkingIdx < 0) return
const tc = output.content[thinkingIdx]
if (tc && tc.type === "thinking") {
stream.push({
type: "thinking_end",
contentIndex: thinkingIdx,
content: (tc as { thinking: string }).thinking,
partial: output,
})
}
thinkingIdx = -1
}
const handleEvent = (event: unknown) => {
@@ -240,6 +229,7 @@ export function createStreamCommandCode(deps: CoreDependencies) {
switch (event.type) {
case "text-delta": {
endThinking()
if (!textBlock) {
textBlock = { type: "text", text: "" }
output.content.push(textBlock)
@@ -268,12 +258,32 @@ export function createStreamCommandCode(deps: CoreDependencies) {
case "reasoning-delta": {
endTextBlock()
thinkingBlock.push(stringValue(event.text) ?? "")
const delta = stringValue(event.text) ?? ""
if (thinkingIdx < 0) {
output.content.push({ type: "thinking", thinking: delta })
thinkingIdx = output.content.length - 1
stream.push({
type: "thinking_start",
contentIndex: thinkingIdx,
partial: output,
})
} else {
const tc = output.content[thinkingIdx]
if (tc && tc.type === "thinking") {
;(tc as { thinking: string }).thinking += delta
}
}
stream.push({
type: "thinking_delta",
contentIndex: thinkingIdx,
delta,
partial: output,
})
break
}
case "reasoning-end": {
flushThinkingBlock()
endThinking()
break
}
@@ -283,6 +293,7 @@ export function createStreamCommandCode(deps: CoreDependencies) {
case "tool-call": {
endTextBlock()
endThinking()
const toolCall: ToolCallContent = {
type: "toolCall",
id: stringValue(event.toolCallId) ?? "",
@@ -442,7 +453,7 @@ export function createStreamCommandCode(deps: CoreDependencies) {
}
endTextBlock()
flushThinkingBlock()
endThinking()
stream.push({
type: "done",
+90
View File
@@ -0,0 +1,90 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
// MODEL_COSTS is a module-level const in index.ts. We verify the pricing
// overlay by importing the map through a dedicated re-export so tests don't
// need to spin up the full extension.
//
// To keep the test self-contained without importing the full extension (which
// requires ExtensionAPI), we read the source and extract the constant at
// runtime. A cleaner approach would be a dedicated src/pricing.ts module,
// but for now we verify the known cost entries directly.
import { readFileSync } from "node:fs"
import { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url"
const __dirname = dirname(fileURLToPath(import.meta.url))
const indexSource = readFileSync(resolve(__dirname, "..", "index.ts"), "utf-8")
// Extract MODEL_COSTS object from index.ts source using a simple parse.
// The map is written as a Record<string, {input:number,output:number,...}>
// so we eval it in a sandboxed context.
const match = indexSource.match(
/const MODEL_COSTS:\s*Record<string,\s*CommandCodeModelCost>\s*=\s*\{([\s\S]*?)\n\}/,
)
assert.ok(match, "MODEL_COSTS constant should exist in index.ts")
// Parse the cost entries from the extracted block.
const costBlock = match[1]
const entries: Record<string, { input: number; output: number }> = {}
for (const line of costBlock.split("\n")) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith("//")) continue
const entryMatch = trimmed.match(/^"([^"]+)":\s*\{\s*input:\s*([\d.]+),\s*output:\s*([\d.]+)/)
if (entryMatch) {
entries[entryMatch[1]] = {
input: Number(entryMatch[2]),
output: Number(entryMatch[3]),
}
}
}
describe("MODEL_COSTS pricing overlay", () => {
it("covers known Command Code models with non-zero pricing", () => {
const knownModels = [
"deepseek/deepseek-v4-flash",
"deepseek/deepseek-v4-pro",
"claude-sonnet-4-6",
"claude-opus-4-7",
"Qwen/Qwen3.7-Max",
"gpt-5.5",
"stepfun/Step-3.5-Flash",
]
for (const id of knownModels) {
const cost = entries[id]
assert.ok(cost, `MODEL_COSTS should include "${id}"`)
assert.ok(cost.input > 0, `"${id}" input cost should be > 0`)
assert.ok(cost.output > 0, `"${id}" output cost should be > 0`)
}
})
it("includes promotional pricing notes in comments", () => {
// The DeepSeek V4 Pro 4× deal and Qwen 3.7 Max 2× deal should be
// documented in the source comments.
assert.ok(
costBlock.includes("4× usage deal") || costBlock.includes("75% off"),
"DeepSeek V4 Pro promotional pricing should be documented",
)
assert.ok(
costBlock.includes("2× usage deal") || costBlock.includes("50% off"),
"Qwen 3.7 Max promotional pricing should be documented",
)
})
it("has cache pricing for models that support it", () => {
// Claude models should have non-zero cacheRead and cacheWrite costs.
const claudeModels = ["claude-sonnet-4-6", "claude-opus-4-7"]
for (const id of claudeModels) {
const fullEntryMatch = costBlock.match(
new RegExp(
`"${id.replace(/\//g, "\\\\")}":\\s*\\{[^}]+cacheRead:\\s*([\\d.]+)[^}]+cacheWrite:\\s*([\\d.]+)`,
),
)
assert.ok(fullEntryMatch, `"${id}" should have cacheRead and cacheWrite fields`)
assert.ok(Number(fullEntryMatch[1]) > 0, `"${id}" cacheRead should be > 0`)
assert.ok(Number(fullEntryMatch[2]) > 0, `"${id}" cacheWrite should be > 0`)
}
})
})
+60
View File
@@ -223,6 +223,66 @@ describe("streamCommandCode — successful streams", () => {
if (done?.type !== "done") throw new Error("expected done")
assert.equal(done.message.content[0]?.type, "thinking")
})
it("closes thinking block before text when reasoning-end is missing", async () => {
server.mockResponse({
type: "success",
events: [
JSON.stringify({ type: "reasoning-start" }),
JSON.stringify({ type: "reasoning-delta", text: "thinking" }),
JSON.stringify({ type: "text-delta", text: "answer" }),
JSON.stringify({ type: "finish", finishReason: "stop" }),
],
})
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
const events = await collectEvents(
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
)
assert.deepEqual(eventTypes(events), [
"start",
"thinking_start",
"thinking_delta",
"thinking_end",
"text_start",
"text_delta",
"text_end",
"done",
])
})
it("closes thinking block before tool-call when reasoning-end is missing", async () => {
server.mockResponse({
type: "success",
events: [
JSON.stringify({ type: "reasoning-start" }),
JSON.stringify({ type: "reasoning-delta", text: "thinking" }),
JSON.stringify({
type: "tool-call",
toolCallId: "call_1",
toolName: "read_file",
input: JSON.stringify({ path: "/tmp/x" }),
}),
JSON.stringify({ type: "finish", finishReason: "tool-calls" }),
],
})
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
const events = await collectEvents(
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
)
assert.deepEqual(eventTypes(events), [
"start",
"thinking_start",
"thinking_delta",
"thinking_end",
"toolcall_start",
"toolcall_end",
"done",
])
})
})
describe("streamCommandCode — request serialization", () => {