diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ca1a66..e2ece99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Fix Oh My Pi extension validation by avoiding the missing `calculateCost` export from OMP's legacy `pi-ai` shim. + ## 0.4.1 - 2026-06-16 - Use the explicit `$COMMANDCODE_API_KEY` provider registration syntax expected by newer pi versions, removing the startup deprecation warning while keeping legacy placeholder compatibility. diff --git a/index.ts b/index.ts index 1295816..b7fb6d9 100644 --- a/index.ts +++ b/index.ts @@ -12,10 +12,11 @@ * Models are fetched from Command Code's Provider API at startup. */ -import { AssistantMessageEventStream, calculateCost } from "@earendil-works/pi-ai" +import { AssistantMessageEventStream } 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 { calculateCommandCodeCost } from "./src/cost.ts" import { DEFAULT_MODELS_URL, fetchCommandCodeModels } from "./src/models.ts" import { getApiKey, login, refreshToken } from "./src/oauth.ts" @@ -69,7 +70,7 @@ const MODEL_COSTS: Record = { const streamCommandCode = createStreamCommandCode({ createStream: () => new AssistantMessageEventStream(), - calculateCost, + calculateCost: calculateCommandCodeCost, apiBase: API_BASE, }) diff --git a/package.json b/package.json index 631df41..2a85fc9 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-pricing.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-stream.ts && tsx tests/test-retry.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-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-stream.ts && tsx tests/test-retry.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}'", @@ -40,7 +40,8 @@ "test:stream": "tsx tests/test-stream.ts", "test:retry": "tsx tests/test-retry.ts", "test:pi-local": "node tests/test-pi-local.mjs", - "test:smoke": "node tests/test-smoke.mjs" + "test:smoke": "node tests/test-smoke.mjs", + "test:cost": "tsx tests/test-cost.ts" }, "pi": { "extensions": [ diff --git a/src/cost.ts b/src/cost.ts new file mode 100644 index 0000000..290bf51 --- /dev/null +++ b/src/cost.ts @@ -0,0 +1,19 @@ +/** + * Local cost calculation for Command Code usage. + * + * Mirrors pi-ai's `calculateCost` arithmetic exactly. The provider ships its + * own copy because Oh My Pi's legacy pi-ai shim does not export + * `calculateCost`, which broke extension installation there (issue #24). + * `tests/test-cost.ts` locks this implementation to the pi-ai original. + */ + +import type { ModelLike, Usage } from "./types.ts" + +export function calculateCommandCodeCost(model: ModelLike, usage: Usage): void { + usage.cost.input = (model.cost.input / 1_000_000) * usage.input + usage.cost.output = (model.cost.output / 1_000_000) * usage.output + usage.cost.cacheRead = (model.cost.cacheRead / 1_000_000) * usage.cacheRead + usage.cost.cacheWrite = (model.cost.cacheWrite / 1_000_000) * usage.cacheWrite + usage.cost.total = + usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite +} diff --git a/src/types.ts b/src/types.ts index 7604e2c..d2a9abe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -50,11 +50,19 @@ export interface AssistantMessageLike { timestamp: number } +export interface ModelCost { + input: number + output: number + cacheRead: number + cacheWrite: number +} + export interface ModelLike { id: string api: unknown provider: string maxTokens: number + cost: ModelCost } export interface MessageLike { diff --git a/tests/helpers.ts b/tests/helpers.ts index bba0a6c..8428cc9 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -79,6 +79,7 @@ export function makeModel(overrides: Partial = {}): ModelLike { api: "commandcode-custom", provider: "commandcode", maxTokens: 384_000, + cost: { input: 0.14, output: 0.28, cacheRead: 0.028, cacheWrite: 0 }, ...overrides, } } diff --git a/tests/test-cost.ts b/tests/test-cost.ts new file mode 100644 index 0000000..ae975d1 --- /dev/null +++ b/tests/test-cost.ts @@ -0,0 +1,99 @@ +/** + * Regression test for the local cost calculation. + * + * The provider ships its own cost function because Oh My Pi's legacy pi-ai + * shim does not export `calculateCost` (see issue #24). This test locks the + * local implementation to pi-ai's upstream `calculateCost` so the two cannot + * drift while pi remains the reference host. + */ + +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { calculateCost, type Model } from "@earendil-works/pi-ai" + +import { calculateCommandCodeCost } from "../src/cost.ts" +import type { Usage } from "../src/types.ts" + +type CostTable = Model<"openai-completions">["cost"] + +const COST_FIXTURES: Record = { + "zero-cost-model": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + "claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + "deepseek/deepseek-v4-pro": { + input: 0.435, + output: 0.87, + cacheRead: 0.003625, + cacheWrite: 0, + }, + "Qwen/Qwen3.7-Max": { input: 1.25, output: 3.75, cacheRead: 0.25, cacheWrite: 1.56 }, + "gpt-5.5": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, +} + +const USAGE_CASES = [ + { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + { input: 1, output: 1, cacheRead: 1, cacheWrite: 1 }, + { input: 812, output: 187, cacheRead: 52_000, cacheWrite: 3_100 }, + { input: 1_000_000, output: 65_536, cacheRead: 998_877, cacheWrite: 123_456 }, + { input: 7, output: 999_999_999, cacheRead: 0.5, cacheWrite: 42 }, +] + +function piAiModel(id: string, cost: CostTable): Model<"openai-completions"> { + return { + id, + name: id, + api: "openai-completions", + provider: "commandcode", + baseUrl: "https://api.commandcode.ai", + reasoning: false, + input: ["text"], + cost, + contextWindow: 1_000_000, + maxTokens: 65_536, + } +} + +function freshUsage(tokens: (typeof USAGE_CASES)[number]): Usage { + return { + ...tokens, + totalTokens: tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + } +} + +describe("calculateCommandCodeCost()", () => { + it("matches pi-ai calculateCost exactly for all cost fields", () => { + for (const [id, cost] of Object.entries(COST_FIXTURES)) { + const model = piAiModel(id, cost) + + for (const tokens of USAGE_CASES) { + const ours = freshUsage(tokens) + const upstream = freshUsage(tokens) + + calculateCommandCodeCost(model, ours) + calculateCost(model, upstream) + + for (const key of ["input", "output", "cacheRead", "cacheWrite", "total"] as const) { + assert.equal( + ours.cost[key], + upstream.cost[key], + `${id} cost.${key} for tokens=${JSON.stringify(tokens)}`, + ) + } + } + } + }) + + it("writes the total as the sum of all cost components", () => { + const model = piAiModel("claude-sonnet-4-6", COST_FIXTURES["claude-sonnet-4-6"]) + const usage = freshUsage({ input: 1_000, output: 500, cacheRead: 10_000, cacheWrite: 2_000 }) + + calculateCommandCodeCost(model, usage) + + assert.equal( + usage.cost.total, + usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite, + ) + assert.ok(usage.cost.total > 0) + }) +})