Merge pull request #7 from skyscribe-yf/main
feat(stream,models): incremental reasoning streaming + live display pricing
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
Generated
+2194
-2666
File diff suppressed because it is too large
Load Diff
+12
-4
@@ -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
@@ -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",
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user