feat(api): use official provider endpoints
This commit is contained in:
@@ -1,289 +0,0 @@
|
||||
import { createServer, type IncomingHttpHeaders, type Server } from "node:http"
|
||||
|
||||
import {
|
||||
createStreamCommandCode,
|
||||
type AssistantMessageEvent,
|
||||
type AssistantMessageEventStreamLike,
|
||||
type ContextLike,
|
||||
type CoreDependencies,
|
||||
type ModelLike,
|
||||
type Usage,
|
||||
} from "../src/core.ts"
|
||||
|
||||
export function createTestEventStream(): AssistantMessageEventStreamLike {
|
||||
const events: AssistantMessageEvent[] = []
|
||||
const waiters: Array<() => void> = []
|
||||
let ended = false
|
||||
|
||||
const wake = () => {
|
||||
const waiter = waiters.shift()
|
||||
if (waiter) waiter()
|
||||
}
|
||||
|
||||
return {
|
||||
push(event: AssistantMessageEvent) {
|
||||
events.push(event)
|
||||
wake()
|
||||
},
|
||||
end() {
|
||||
ended = true
|
||||
while (waiters.length > 0) wake()
|
||||
},
|
||||
[Symbol.asyncIterator]() {
|
||||
let index = 0
|
||||
return {
|
||||
async next(): Promise<IteratorResult<AssistantMessageEvent>> {
|
||||
while (index >= events.length && !ended) {
|
||||
await new Promise<void>((resolve) => waiters.push(resolve))
|
||||
}
|
||||
if (index < events.length) {
|
||||
const value = events[index]
|
||||
index += 1
|
||||
return { done: false, value }
|
||||
}
|
||||
return { done: true, value: undefined }
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectEvents(
|
||||
stream: AssistantMessageEventStreamLike,
|
||||
timeoutMs = 2_000,
|
||||
): Promise<AssistantMessageEvent[]> {
|
||||
const events: AssistantMessageEvent[] = []
|
||||
|
||||
const collect = async () => {
|
||||
for await (const event of stream) {
|
||||
events.push(event)
|
||||
if (event.type === "done" || event.type === "error") break
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
return await Promise.race([
|
||||
collect(),
|
||||
new Promise<AssistantMessageEvent[]>((_, reject) => {
|
||||
setTimeout(
|
||||
() => reject(new Error(`Timed out collecting stream events after ${timeoutMs}ms`)),
|
||||
timeoutMs,
|
||||
)
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
export function makeModel(overrides: Partial<ModelLike> = {}): ModelLike {
|
||||
return {
|
||||
id: "deepseek/deepseek-v4-flash",
|
||||
api: "commandcode-custom",
|
||||
provider: "commandcode",
|
||||
maxTokens: 384_000,
|
||||
cost: { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function makeContext(overrides: Partial<ContextLike> = {}): ContextLike {
|
||||
return {
|
||||
systemPrompt: "You are a test assistant.",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
tools: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export interface TestDepsResult {
|
||||
streamCommandCode: ReturnType<typeof createStreamCommandCode>
|
||||
calculatedUsages: Usage[]
|
||||
}
|
||||
|
||||
export function createTestDeps(overrides: Partial<CoreDependencies> = {}): TestDepsResult {
|
||||
const calculatedUsages: Usage[] = []
|
||||
const streamCommandCode = createStreamCommandCode({
|
||||
createStream: createTestEventStream,
|
||||
calculateCost: (_model, usage) => {
|
||||
calculatedUsages.push({
|
||||
...usage,
|
||||
cost: { ...usage.cost },
|
||||
})
|
||||
},
|
||||
env: {},
|
||||
authPaths: [],
|
||||
now: () => new Date("2026-05-05T12:00:00Z").getTime(),
|
||||
uuid: () => "00000000-0000-4000-8000-000000000000",
|
||||
cwd: () => "/repo",
|
||||
delay: async () => {},
|
||||
...overrides,
|
||||
})
|
||||
return { streamCommandCode, calculatedUsages }
|
||||
}
|
||||
|
||||
type SuccessPlan = {
|
||||
type: "success"
|
||||
status?: number
|
||||
events?: string[]
|
||||
chunks?: string[]
|
||||
delays?: number[]
|
||||
hangAfterLast?: boolean
|
||||
/** Delay in ms before the server starts sending the response. */
|
||||
responseDelay?: number
|
||||
}
|
||||
|
||||
type ErrorPlan = {
|
||||
type: "error"
|
||||
status: number
|
||||
body: string
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
export type ResponsePlan = SuccessPlan | ErrorPlan
|
||||
|
||||
function headersToRecord(headers: IncomingHttpHeaders): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (typeof value === "string") out[key] = value
|
||||
else if (Array.isArray(value)) out[key] = value.join(", ")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export interface MockCommandCodeServer {
|
||||
baseUrl(): string
|
||||
mockResponse(plan: ResponsePlan): void
|
||||
mockResponseQueue(plans: ResponsePlan[]): void
|
||||
reset(): void
|
||||
close(): Promise<void>
|
||||
lastRequestBody(): unknown
|
||||
lastRequestHeaders(): Record<string, string>
|
||||
requestCount(): number
|
||||
responseClosedBeforeEnd(): boolean
|
||||
}
|
||||
|
||||
export async function startMockCommandCodeServer(): Promise<MockCommandCodeServer> {
|
||||
let planQueue: ResponsePlan[] = [{ type: "success", events: [] }]
|
||||
let lastBody: unknown
|
||||
let lastHeaders: Record<string, string> = {}
|
||||
let requests = 0
|
||||
let closedBeforeEnd = false
|
||||
let port = 0
|
||||
|
||||
const server: Server = createServer((req, res) => {
|
||||
if (req.method !== "POST" || req.url !== "/alpha/generate") {
|
||||
res.writeHead(404)
|
||||
res.end("Not found")
|
||||
return
|
||||
}
|
||||
|
||||
requests += 1
|
||||
lastHeaders = headersToRecord(req.headers)
|
||||
let body = ""
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
body += chunk.toString("utf-8")
|
||||
})
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(body)
|
||||
lastBody = parsed
|
||||
} catch {
|
||||
lastBody = undefined
|
||||
}
|
||||
|
||||
// Pop the first plan from the queue; keep the last one as fallback.
|
||||
const plan = planQueue.length > 1 ? planQueue.shift()! : planQueue[0]
|
||||
|
||||
if (plan.type === "error") {
|
||||
const headers: Record<string, string> = { "Content-Type": "text/plain", ...plan.headers }
|
||||
res.writeHead(plan.status, headers)
|
||||
res.end(plan.body)
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(plan.status ?? 200, {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Transfer-Encoding": "chunked",
|
||||
})
|
||||
|
||||
let ended = false
|
||||
res.on("close", () => {
|
||||
if (!ended) closedBeforeEnd = true
|
||||
})
|
||||
|
||||
const chunks = plan.chunks ?? (plan.events ?? []).map((event) => `${event}\n`)
|
||||
const delays = plan.delays ?? chunks.map(() => 0)
|
||||
let index = 0
|
||||
|
||||
const sendNext = () => {
|
||||
if (index >= chunks.length) {
|
||||
if (!plan.hangAfterLast) {
|
||||
ended = true
|
||||
res.end()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
res.write(chunks[index])
|
||||
index += 1
|
||||
if (index < chunks.length) {
|
||||
setTimeout(sendNext, delays[index] ?? 0)
|
||||
} else if (!plan.hangAfterLast) {
|
||||
ended = true
|
||||
res.end()
|
||||
}
|
||||
}
|
||||
|
||||
if (plan.responseDelay) {
|
||||
setTimeout(sendNext, plan.responseDelay)
|
||||
} else {
|
||||
sendNext()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, () => {
|
||||
const address = server.address()
|
||||
if (typeof address === "object" && address) port = address.port
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
baseUrl: () => `http://127.0.0.1:${port}`,
|
||||
mockResponse(plan: ResponsePlan) {
|
||||
planQueue = [plan]
|
||||
},
|
||||
mockResponseQueue(plans: ResponsePlan[]) {
|
||||
planQueue = [...plans]
|
||||
},
|
||||
reset() {
|
||||
planQueue = [{ type: "success", events: [] }]
|
||||
lastBody = undefined
|
||||
lastHeaders = {}
|
||||
requests = 0
|
||||
closedBeforeEnd = false
|
||||
},
|
||||
close() {
|
||||
return new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
},
|
||||
lastRequestBody: () => lastBody,
|
||||
lastRequestHeaders: () => lastHeaders,
|
||||
requestCount: () => requests,
|
||||
responseClosedBeforeEnd: () => closedBeforeEnd,
|
||||
}
|
||||
}
|
||||
|
||||
export function objectAt(value: unknown, path: readonly string[]): unknown {
|
||||
let current = value
|
||||
for (const key of path) {
|
||||
if (Array.isArray(current)) {
|
||||
const index = Number(key)
|
||||
if (!Number.isInteger(index)) return undefined
|
||||
current = current[index]
|
||||
continue
|
||||
}
|
||||
if (typeof current !== "object" || current === null) return undefined
|
||||
current = Object.getOwnPropertyDescriptor(current, key)?.value
|
||||
}
|
||||
return current
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* Abort tests against the real streamCommandCode core.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict"
|
||||
import { after, before, beforeEach, describe, it } from "node:test"
|
||||
|
||||
import {
|
||||
collectEvents,
|
||||
createTestDeps,
|
||||
makeContext,
|
||||
makeModel,
|
||||
startMockCommandCodeServer,
|
||||
type MockCommandCodeServer,
|
||||
} from "./helpers.ts"
|
||||
|
||||
let server: MockCommandCodeServer
|
||||
|
||||
before(async () => {
|
||||
server = await startMockCommandCodeServer()
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await server.close()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
server.reset()
|
||||
})
|
||||
|
||||
describe("streamCommandCode — abort behavior", () => {
|
||||
it("emits aborted error when signal is already aborted", async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), {
|
||||
apiKey: "mock-key",
|
||||
signal: controller.signal,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
events.map((event) => event.type),
|
||||
["start", "error"],
|
||||
)
|
||||
const error = events.at(-1)
|
||||
assert.equal(error?.type, "error")
|
||||
if (error?.type !== "error") throw new Error("expected error")
|
||||
assert.equal(error.reason, "aborted")
|
||||
assert.equal(error.error.stopReason, "aborted")
|
||||
assert.equal(server.requestCount(), 0)
|
||||
})
|
||||
|
||||
it("emits aborted error and cancels the response reader mid-stream", async () => {
|
||||
server.mockResponse({
|
||||
type: "success",
|
||||
events: [JSON.stringify({ type: "text-delta", text: "first" })],
|
||||
hangAfterLast: true,
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
const stream = streamCommandCode(makeModel(), makeContext(), {
|
||||
apiKey: "mock-key",
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
setTimeout(() => controller.abort(), 50)
|
||||
const events = await collectEvents(stream, 2_000)
|
||||
|
||||
assert.ok(
|
||||
events.some((event) => event.type === "text_delta"),
|
||||
"stream should process data before abort",
|
||||
)
|
||||
const error = events.at(-1)
|
||||
assert.equal(error?.type, "error")
|
||||
if (error?.type !== "error") throw new Error("expected error")
|
||||
assert.equal(error.reason, "aborted")
|
||||
assert.equal(error.error.errorMessage, "Request aborted")
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
assert.ok(server.responseClosedBeforeEnd(), "abort should close the hanging upstream response")
|
||||
})
|
||||
})
|
||||
@@ -1,183 +0,0 @@
|
||||
/**
|
||||
* 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 documented per-million-token arithmetic
|
||||
* without installing another pi-ai runtime next to the extension.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import { calculateCommandCodeCost } from "../src/cost.ts"
|
||||
import type { Usage } from "../src/types.ts"
|
||||
|
||||
interface CostRates {
|
||||
input: number
|
||||
output: number
|
||||
cacheRead: number
|
||||
cacheWrite: number
|
||||
}
|
||||
|
||||
interface CostTable extends CostRates {
|
||||
tiers?: Array<CostRates & { inputTokensAbove: number }>
|
||||
}
|
||||
|
||||
const COST_FIXTURES: Record<string, CostTable> = {
|
||||
"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: 2.5, output: 7.5, cacheRead: 0.5, cacheWrite: 3.13 },
|
||||
"Qwen/Qwen3.7-Flash": {
|
||||
input: 0.03,
|
||||
output: 0.13,
|
||||
cacheRead: 0.006,
|
||||
cacheWrite: 0.038,
|
||||
tiers: [
|
||||
{ inputTokensAbove: 32_000, input: 0.1, output: 0.4, cacheRead: 0.02, cacheWrite: 0.125 },
|
||||
{ inputTokensAbove: 256_000, input: 0.2, output: 0.8, cacheRead: 0.04, cacheWrite: 0.25 },
|
||||
],
|
||||
},
|
||||
"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 commandCodeModel(id: string, cost: CostTable) {
|
||||
return {
|
||||
id,
|
||||
api: "commandcode-custom",
|
||||
provider: "commandcode",
|
||||
cost,
|
||||
maxTokens: 65_536,
|
||||
}
|
||||
}
|
||||
|
||||
function assertClose(actual: number, expected: number) {
|
||||
assert.ok(
|
||||
Math.abs(actual - expected) <=
|
||||
Number.EPSILON * Math.max(1, Math.abs(actual), Math.abs(expected)),
|
||||
`expected ${actual} to be close to ${expected}`,
|
||||
)
|
||||
}
|
||||
|
||||
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 },
|
||||
}
|
||||
}
|
||||
|
||||
function expectedCost(cost: CostTable, tokens: (typeof USAGE_CASES)[number]): Usage["cost"] {
|
||||
const inputTokens = tokens.input + tokens.cacheRead + tokens.cacheWrite
|
||||
let rates: CostRates = cost
|
||||
let matchedThreshold = -1
|
||||
for (const tier of cost.tiers ?? []) {
|
||||
if (inputTokens > tier.inputTokensAbove && tier.inputTokensAbove > matchedThreshold) {
|
||||
rates = tier
|
||||
matchedThreshold = tier.inputTokensAbove
|
||||
}
|
||||
}
|
||||
|
||||
const input = (rates.input / 1_000_000) * tokens.input
|
||||
const output = (rates.output / 1_000_000) * tokens.output
|
||||
const cacheRead = (rates.cacheRead / 1_000_000) * tokens.cacheRead
|
||||
const cacheWrite = (rates.cacheWrite * tokens.cacheWrite) / 1_000_000
|
||||
return {
|
||||
input,
|
||||
output,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
total: input + output + cacheRead + cacheWrite,
|
||||
}
|
||||
}
|
||||
|
||||
describe("calculateCommandCodeCost()", () => {
|
||||
it("applies per-million-token rates to all cost fields", () => {
|
||||
for (const [id, cost] of Object.entries(COST_FIXTURES)) {
|
||||
const model = commandCodeModel(id, cost)
|
||||
|
||||
for (const tokens of USAGE_CASES) {
|
||||
const usage = freshUsage(tokens)
|
||||
calculateCommandCodeCost(model, usage)
|
||||
|
||||
assert.deepEqual(
|
||||
usage.cost,
|
||||
expectedCost(cost, tokens),
|
||||
`${id} cost for tokens=${JSON.stringify(tokens)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("applies the highest request-wide input tier above its threshold", () => {
|
||||
const model = commandCodeModel("Qwen/Qwen3.7-Flash", COST_FIXTURES["Qwen/Qwen3.7-Flash"])
|
||||
|
||||
const atThreshold = freshUsage({
|
||||
input: 32_000,
|
||||
output: 1_000,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
})
|
||||
calculateCommandCodeCost(model, atThreshold)
|
||||
assertClose(atThreshold.cost.input, (0.03 * 32_000) / 1_000_000)
|
||||
|
||||
const aboveFirstTier = freshUsage({
|
||||
input: 30_000,
|
||||
output: 1_000,
|
||||
cacheRead: 2_001,
|
||||
cacheWrite: 0,
|
||||
})
|
||||
calculateCommandCodeCost(model, aboveFirstTier)
|
||||
assertClose(aboveFirstTier.cost.input, (0.1 * 30_000) / 1_000_000)
|
||||
assertClose(aboveFirstTier.cost.cacheRead, (0.02 * 2_001) / 1_000_000)
|
||||
|
||||
const aboveHighestTier = freshUsage({
|
||||
input: 100_000,
|
||||
output: 1_000,
|
||||
cacheRead: 156_001,
|
||||
cacheWrite: 0,
|
||||
})
|
||||
calculateCommandCodeCost(model, aboveHighestTier)
|
||||
assertClose(aboveHighestTier.cost.input, (0.2 * 100_000) / 1_000_000)
|
||||
assertClose(aboveHighestTier.cost.output, (0.8 * 1_000) / 1_000_000)
|
||||
})
|
||||
|
||||
it("prices one-hour cache writes at twice the active input rate", () => {
|
||||
const model = commandCodeModel("claude-sonnet-4-6", COST_FIXTURES["claude-sonnet-4-6"])
|
||||
const usage = freshUsage({ input: 0, output: 0, cacheRead: 0, cacheWrite: 1_000 })
|
||||
usage.cacheWrite1h = 400
|
||||
|
||||
calculateCommandCodeCost(model, usage)
|
||||
|
||||
const expectedShortWrite = (3.75 * 600) / 1_000_000
|
||||
const expectedLongWrite = (3 * 2 * 400) / 1_000_000
|
||||
assertClose(usage.cost.cacheWrite, expectedShortWrite + expectedLongWrite)
|
||||
})
|
||||
|
||||
it("writes the total as the sum of all cost components", () => {
|
||||
const model = commandCodeModel("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)
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,8 @@ import { join } from "node:path"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import {
|
||||
apiForModelId,
|
||||
baseUrlForModel,
|
||||
commandCodeModelsFromApiResponse,
|
||||
commandCodeModelsFromCache,
|
||||
DEFAULT_MODELS_TIMEOUT_MS,
|
||||
@@ -37,6 +39,7 @@ const EXPECTED_MODELS: readonly CommandCodeModel[] = [
|
||||
{
|
||||
id: "Qwen/Qwen3.7-Max",
|
||||
name: "Qwen 3.7 Max (CC)",
|
||||
api: "openai-completions",
|
||||
reasoning: false,
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 65_536,
|
||||
@@ -84,6 +87,19 @@ describe("commandCodeModelsFromApiResponse()", () => {
|
||||
assert.deepEqual(commandCodeModelsFromApiResponse(API_RESPONSE), EXPECTED_MODELS)
|
||||
})
|
||||
|
||||
it("routes Claude models to Anthropic Messages and all others to Chat Completions", () => {
|
||||
assert.equal(apiForModelId("claude-sonnet-4-6"), "anthropic-messages")
|
||||
assert.equal(apiForModelId("gpt-5.6-sol"), "openai-completions")
|
||||
assert.equal(
|
||||
baseUrlForModel("https://api.commandcode.ai/provider/v1/", "openai-completions"),
|
||||
"https://api.commandcode.ai/provider/v1",
|
||||
)
|
||||
assert.equal(
|
||||
baseUrlForModel("https://api.commandcode.ai/provider/v1/", "anthropic-messages"),
|
||||
"https://api.commandcode.ai/provider",
|
||||
)
|
||||
})
|
||||
|
||||
it("matches command-code@1.15.1 image input capabilities", () => {
|
||||
assert.deepEqual(inputModalitiesForModel("gpt-5.6-luna"), ["text", "image"])
|
||||
assert.deepEqual(inputModalitiesForModel("meta/muse-spark-1.2"), ["text", "image"])
|
||||
|
||||
@@ -77,7 +77,7 @@ const server = createServer((req, res) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method !== "POST" || req.url !== "/alpha/generate") {
|
||||
if (req.method !== "POST" || req.url !== "/provider/v1/chat/completions") {
|
||||
res.writeHead(404)
|
||||
res.end("Not found")
|
||||
return
|
||||
@@ -103,14 +103,19 @@ const server = createServer((req, res) => {
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Content-Type": "text/event-stream; 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`,
|
||||
`data: ${JSON.stringify({ id: "mock", object: "chat.completion.chunk", choices: [{ index: 0, delta: { role: "assistant", content: "mock-omp-ok" }, finish_reason: null }] })}\n\n`,
|
||||
)
|
||||
res.end()
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ id: "mock", object: "chat.completion.chunk", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\n`,
|
||||
)
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ id: "mock", object: "chat.completion.chunk", choices: [], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } })}\n\n`,
|
||||
)
|
||||
res.end("data: [DONE]\n\n")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -129,7 +134,7 @@ function runOmp(args, timeoutMs = 30_000) {
|
||||
USERPROFILE: tempHome,
|
||||
PI_CODING_AGENT_DIR: join(tempHome, ".omp", "agent"),
|
||||
COMMANDCODE_API_KEY: "mock-key",
|
||||
COMMANDCODE_API_BASE: apiBase,
|
||||
COMMANDCODE_API_BASE: `${apiBase}/provider/v1`,
|
||||
COMMANDCODE_MODELS_URL: `${apiBase}/provider/v1/models`,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
@@ -185,8 +190,8 @@ try {
|
||||
"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")
|
||||
assert.equal(lastRequestBody?.model, TEST_MODEL)
|
||||
assert.ok(Array.isArray(lastRequestBody?.messages))
|
||||
|
||||
console.log("[omp-compat] PASS")
|
||||
} finally {
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { after, before, beforeEach, describe, it } from "node:test"
|
||||
|
||||
import {
|
||||
commandCodeErrorMessage,
|
||||
normalizeCommandCodeErrorMessage,
|
||||
normalizeCommandCodeMessage,
|
||||
} from "../src/overflow.ts"
|
||||
import {
|
||||
collectEvents,
|
||||
createTestDeps,
|
||||
makeContext,
|
||||
makeModel,
|
||||
startMockCommandCodeServer,
|
||||
type MockCommandCodeServer,
|
||||
} from "./helpers.ts"
|
||||
|
||||
let server: MockCommandCodeServer
|
||||
|
||||
before(async () => {
|
||||
server = await startMockCommandCodeServer()
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await server.close()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
server.reset()
|
||||
})
|
||||
|
||||
describe("Command Code overflow normalization", () => {
|
||||
it("normalizes Command Code context errors to pi's generic overflow marker", () => {
|
||||
const normalized = normalizeCommandCodeErrorMessage("Prompt token limit exceeded")
|
||||
|
||||
assert.equal(normalized, "context_length_exceeded: Prompt token limit exceeded")
|
||||
})
|
||||
|
||||
it("is idempotent and leaves unrelated, rate-limit, and capacity errors unchanged", () => {
|
||||
assert.equal(
|
||||
normalizeCommandCodeErrorMessage("context_length_exceeded: Prompt token limit exceeded"),
|
||||
undefined,
|
||||
)
|
||||
assert.equal(normalizeCommandCodeErrorMessage("OpenAI request failed"), undefined)
|
||||
assert.equal(
|
||||
normalizeCommandCodeErrorMessage("Prompt token limit exceeded due to rate limit"),
|
||||
undefined,
|
||||
)
|
||||
assert.equal(
|
||||
normalizeCommandCodeErrorMessage("Command Code API error 429: context window exceeded"),
|
||||
undefined,
|
||||
)
|
||||
assert.equal(
|
||||
normalizeCommandCodeErrorMessage("context window exceeded: status: 429"),
|
||||
undefined,
|
||||
)
|
||||
assert.equal(
|
||||
normalizeCommandCodeErrorMessage("The input is too long"),
|
||||
"context_length_exceeded: The input is too long",
|
||||
)
|
||||
assert.equal(
|
||||
normalizeCommandCodeErrorMessage("Input exceeds context limit"),
|
||||
"context_length_exceeded: Input exceeds context limit",
|
||||
)
|
||||
assert.equal(
|
||||
normalizeCommandCodeErrorMessage("Context window exceeded: provider capacity reached"),
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
it("scopes finalized message normalization to Command Code", () => {
|
||||
const message = {
|
||||
role: "assistant" as const,
|
||||
provider: "commandcode",
|
||||
stopReason: "error" as const,
|
||||
errorMessage: "model context window exceeded",
|
||||
}
|
||||
|
||||
const normalized = normalizeCommandCodeMessage(message)
|
||||
assert.equal(
|
||||
normalized?.message.errorMessage,
|
||||
"context_length_exceeded: model context window exceeded",
|
||||
)
|
||||
assert.equal(normalizeCommandCodeMessage({ ...message, provider: "openai" }), undefined)
|
||||
assert.equal(normalizeCommandCodeMessage({ ...message, stopReason: "stop" }), undefined)
|
||||
})
|
||||
|
||||
it("extracts nested stream error messages without exposing credentials", () => {
|
||||
assert.equal(
|
||||
commandCodeErrorMessage({
|
||||
error: { details: { errorMessage: "context window exceeded" } },
|
||||
}),
|
||||
"context window exceeded",
|
||||
)
|
||||
})
|
||||
|
||||
it("redacts secrets from finalized provider errors", async () => {
|
||||
server.mockResponse({
|
||||
type: "error",
|
||||
status: 400,
|
||||
body: "api_key=user_secret_value",
|
||||
})
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
||||
)
|
||||
const error = events.at(-1)
|
||||
assert.equal(error?.type, "error")
|
||||
if (error?.type !== "error") throw new Error("expected error")
|
||||
assert.doesNotMatch(error.error.errorMessage ?? "", /user_secret_value/)
|
||||
assert.match(error.error.errorMessage ?? "", /api_key=\[redacted\]/)
|
||||
})
|
||||
|
||||
it("normalizes HTTP error bodies containing nested context errors", async () => {
|
||||
server.mockResponse({
|
||||
type: "error",
|
||||
status: 400,
|
||||
body: JSON.stringify({ error: { message: "Prompt token limit exceeded" } }),
|
||||
})
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
||||
)
|
||||
const error = events.at(-1)
|
||||
|
||||
assert.equal(error?.type, "error")
|
||||
if (error?.type !== "error") throw new Error("expected error")
|
||||
const normalized = normalizeCommandCodeMessage(error.error)
|
||||
assert.match(normalized?.message.errorMessage ?? "", /^context_length_exceeded:/)
|
||||
})
|
||||
|
||||
it("does not normalize an HTTP rate-limit response that mentions context", async () => {
|
||||
server.mockResponse({
|
||||
type: "error",
|
||||
status: 429,
|
||||
body: JSON.stringify({ error: { message: "context window exceeded" } }),
|
||||
})
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
||||
)
|
||||
const error = events.at(-1)
|
||||
|
||||
assert.equal(error?.type, "error")
|
||||
if (error?.type !== "error") throw new Error("expected error")
|
||||
assert.equal(normalizeCommandCodeMessage(error.error), undefined)
|
||||
})
|
||||
|
||||
it("normalizes nested stream error events", async () => {
|
||||
server.mockResponse({
|
||||
type: "success",
|
||||
events: [
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: { details: { message: "model context window exceeded" } },
|
||||
}),
|
||||
],
|
||||
})
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
||||
)
|
||||
const error = events.at(-1)
|
||||
|
||||
assert.equal(error?.type, "error")
|
||||
if (error?.type !== "error") throw new Error("expected error")
|
||||
const normalized = normalizeCommandCodeMessage(error.error)
|
||||
assert.equal(
|
||||
normalized?.message.errorMessage,
|
||||
"context_length_exceeded: model context window exceeded",
|
||||
)
|
||||
|
||||
server.mockResponse({
|
||||
type: "success",
|
||||
events: [
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: { message: "context window exceeded", status: 429 },
|
||||
}),
|
||||
],
|
||||
})
|
||||
const retryEvents = await collectEvents(
|
||||
createTestDeps({ apiBase: server.baseUrl() }).streamCommandCode(makeModel(), makeContext(), {
|
||||
apiKey: "mock-key",
|
||||
}),
|
||||
)
|
||||
const retryError = retryEvents.at(-1)
|
||||
assert.equal(retryError?.type, "error")
|
||||
if (retryError?.type !== "error") throw new Error("expected error")
|
||||
assert.equal(normalizeCommandCodeMessage(retryError.error), undefined)
|
||||
})
|
||||
})
|
||||
+104
-35
@@ -15,7 +15,8 @@ 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"
|
||||
const TEST_MODEL = "gpt-5.4"
|
||||
const CLAUDE_TEST_MODEL = "claude-sonnet-4-6"
|
||||
|
||||
function findPiBinary() {
|
||||
if (process.env.PI_BIN) return process.env.PI_BIN
|
||||
@@ -63,9 +64,17 @@ function modelCatalog() {
|
||||
object: "model",
|
||||
created: 1779824324,
|
||||
owned_by: "command-code",
|
||||
name: "DeepSeek V4 Flash",
|
||||
name: "GPT 5.4",
|
||||
context_length: 1_000_000,
|
||||
},
|
||||
{
|
||||
id: CLAUDE_TEST_MODEL,
|
||||
object: "model",
|
||||
created: 1779824324,
|
||||
owned_by: "command-code",
|
||||
name: "Claude Sonnet 4.6",
|
||||
context_length: 200_000,
|
||||
},
|
||||
{
|
||||
id: "cc-second-model",
|
||||
object: "model",
|
||||
@@ -101,7 +110,9 @@ const server = createServer((req, res) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method !== "POST" || req.url !== "/alpha/generate") {
|
||||
const isOpenAIRequest = req.method === "POST" && req.url === "/provider/v1/chat/completions"
|
||||
const isAnthropicRequest = req.method === "POST" && req.url === "/provider/v1/messages"
|
||||
if (!isOpenAIRequest && !isAnthropicRequest) {
|
||||
res.writeHead(404)
|
||||
res.end("Not found")
|
||||
return
|
||||
@@ -129,12 +140,20 @@ const server = createServer((req, res) => {
|
||||
|
||||
if (overflowMode && overflowRequestCount === 2) {
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" })
|
||||
res.end(JSON.stringify({ error: { message: "Input exceeds context limit" } }))
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: "Input exceeds context limit",
|
||||
type: "invalid_request_error",
|
||||
code: "context_length_exceeded",
|
||||
},
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Transfer-Encoding": "chunked",
|
||||
})
|
||||
const text = overflowMode
|
||||
@@ -144,11 +163,36 @@ const server = createServer((req, res) => {
|
||||
? "compaction-summary"
|
||||
: "overflow-recovered"
|
||||
: "mock-pi-ok"
|
||||
res.write(`${JSON.stringify({ type: "text-delta", text })}\n`)
|
||||
if (isAnthropicRequest) {
|
||||
res.write(
|
||||
`event: message_start\ndata: ${JSON.stringify({ type: "message_start", message: { id: "mock", type: "message", role: "assistant", content: [], model: CLAUDE_TEST_MODEL, stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 0 } } })}\n\n`,
|
||||
)
|
||||
res.write(
|
||||
`event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })}\n\n`,
|
||||
)
|
||||
res.write(
|
||||
`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text } })}\n\n`,
|
||||
)
|
||||
res.write(
|
||||
`event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: 0 })}\n\n`,
|
||||
)
|
||||
res.write(
|
||||
`event: message_delta\ndata: ${JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 1 } })}\n\n`,
|
||||
)
|
||||
res.end(`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`)
|
||||
return
|
||||
}
|
||||
|
||||
res.write(
|
||||
`${JSON.stringify({ type: "finish", finishReason: "stop", totalUsage: { inputTokens: 1, outputTokens: 1 } })}\n`,
|
||||
`data: ${JSON.stringify({ id: "mock", object: "chat.completion.chunk", choices: [{ index: 0, delta: { role: "assistant", content: text }, finish_reason: null }] })}\n\n`,
|
||||
)
|
||||
res.end()
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ id: "mock", object: "chat.completion.chunk", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\n`,
|
||||
)
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ id: "mock", object: "chat.completion.chunk", choices: [], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } })}\n\n`,
|
||||
)
|
||||
res.end("data: [DONE]\n\n")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -170,8 +214,9 @@ const env = {
|
||||
USERPROFILE: tempHome,
|
||||
PI_CODING_AGENT_DIR: agentDir,
|
||||
PI_CODING_AGENT_SESSION_DIR: join(tempHome, "sessions"),
|
||||
COMMANDCODE_API_BASE: apiBase,
|
||||
COMMANDCODE_API_BASE: `${apiBase}/provider/v1`,
|
||||
COMMANDCODE_API_KEY: "mock-key",
|
||||
COMMANDCODE_ZDR: "1",
|
||||
COMMANDCODE_MODELS_URL: `${apiBase}/provider/v1/models`,
|
||||
}
|
||||
|
||||
@@ -403,7 +448,7 @@ async function runRpcExtensionCommands(timeoutMs = 30_000) {
|
||||
event.type === "extension_ui_request" &&
|
||||
event.method === "notify" &&
|
||||
typeof event.message === "string" &&
|
||||
event.message.includes("model count: 2"),
|
||||
event.message.includes("model count: 3"),
|
||||
)
|
||||
|
||||
includeRefreshedModel = true
|
||||
@@ -414,7 +459,7 @@ async function runRpcExtensionCommands(timeoutMs = 30_000) {
|
||||
event.type === "extension_ui_request" &&
|
||||
event.method === "notify" &&
|
||||
typeof event.message === "string" &&
|
||||
event.message.includes("3 models from live"),
|
||||
event.message.includes("4 models from live"),
|
||||
)
|
||||
|
||||
send({ id: "status-after", type: "prompt", message: "/commandcode-status" })
|
||||
@@ -426,7 +471,7 @@ async function runRpcExtensionCommands(timeoutMs = 30_000) {
|
||||
event.type === "extension_ui_request" &&
|
||||
event.method === "notify" &&
|
||||
typeof event.message === "string" &&
|
||||
event.message.includes("model count: 3"),
|
||||
event.message.includes("model count: 4"),
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -565,7 +610,7 @@ try {
|
||||
)
|
||||
assert.equal(recoveryList.code, 0, recoveryList.stderr)
|
||||
const recoveryOutput = recoveryList.stdout || recoveryList.stderr
|
||||
assert.match(recoveryOutput, /deepseek\/deepseek-v4-flash/)
|
||||
assert.match(recoveryOutput, /gpt-5\.4/)
|
||||
assert.match(recoveryOutput, /cc-second-model/)
|
||||
assert.doesNotMatch(recoveryList.stderr, /no valid cached catalog/)
|
||||
assert.doesNotMatch(recoveryList.stderr, /Failed to load extension/)
|
||||
@@ -578,7 +623,7 @@ try {
|
||||
assert.equal(list.code, 0, list.stderr)
|
||||
const listOutput = list.stdout || list.stderr
|
||||
assert.match(listOutput, /commandcode/)
|
||||
assert.match(listOutput, /deepseek\/deepseek-v4-flash/)
|
||||
assert.match(listOutput, /gpt-5\.4/)
|
||||
assert.match(listOutput, /cc-second-model/)
|
||||
assert.equal(modelListRequestCount, 1)
|
||||
assert.doesNotThrow(() => accessSync(modelsCachePath, constants.R_OK))
|
||||
@@ -591,7 +636,7 @@ try {
|
||||
)
|
||||
assert.equal(offlineList.code, 0, offlineList.stderr)
|
||||
const offlineListOutput = offlineList.stdout || offlineList.stderr
|
||||
assert.match(offlineListOutput, /deepseek\/deepseek-v4-flash/)
|
||||
assert.match(offlineListOutput, /gpt-5\.4/)
|
||||
assert.match(offlineListOutput, /cc-second-model/)
|
||||
assert.match(offlineList.stderr, /Using the cached catalog/)
|
||||
|
||||
@@ -659,27 +704,51 @@ try {
|
||||
lastRequestHeaders.authorization.startsWith("Bearer "),
|
||||
"should send a bearer Authorization header",
|
||||
)
|
||||
assert.equal(lastRequestBody?.params?.model, TEST_MODEL)
|
||||
assert.equal(lastRequestBody?.params?.reasoning_effort, "high")
|
||||
const sentTools = lastRequestBody?.params?.tools
|
||||
assert.equal(lastRequestHeaders["x-cmd-zdr"], "1")
|
||||
assert.equal(lastRequestBody?.model, TEST_MODEL)
|
||||
assert.equal(lastRequestBody?.reasoning_effort, "high")
|
||||
const sentTools = lastRequestBody?.tools
|
||||
assert.ok(Array.isArray(sentTools) && sentTools.length > 0)
|
||||
const editTool = sentTools.find((tool) => tool.name === "edit")
|
||||
assert.equal(editTool?.input_schema?.type, "object")
|
||||
assert.equal(editTool?.input_schema?.properties?.edits?.type, "array")
|
||||
assert.equal(editTool?.input_schema?.properties?.edits?.items?.type, "object")
|
||||
const editTool = sentTools.find((tool) => tool.function?.name === "edit")
|
||||
assert.equal(editTool?.function?.parameters?.type, "object")
|
||||
assert.equal(editTool?.function?.parameters?.properties?.edits?.type, "array")
|
||||
assert.equal(editTool?.function?.parameters?.properties?.edits?.items?.type, "object")
|
||||
assert.equal(
|
||||
editTool?.input_schema?.properties?.edits?.items?.properties?.oldText?.type,
|
||||
editTool?.function?.parameters?.properties?.edits?.items?.properties?.oldText?.type,
|
||||
"string",
|
||||
)
|
||||
|
||||
console.log("[pi-local] Claude request through Anthropic Messages endpoint")
|
||||
requestCount = 0
|
||||
const claudePrint = await runPi(
|
||||
[
|
||||
"--no-extensions",
|
||||
"-e",
|
||||
EXT_PATH,
|
||||
"-p",
|
||||
"say mock token",
|
||||
"--provider",
|
||||
"commandcode",
|
||||
"--model",
|
||||
CLAUDE_TEST_MODEL,
|
||||
],
|
||||
30_000,
|
||||
)
|
||||
assert.equal(claudePrint.code, 0, claudePrint.stderr)
|
||||
assert.match(claudePrint.stdout, /mock-pi-ok/)
|
||||
assert.equal(requestCount, 1)
|
||||
assert.equal(lastRequestBody?.model, CLAUDE_TEST_MODEL)
|
||||
assert.equal(lastRequestHeaders["x-api-key"], "mock-key")
|
||||
assert.equal(lastRequestHeaders["x-cmd-zdr"], "1")
|
||||
|
||||
console.log("[pi-local] runtime commands through real RPC extension lifecycle")
|
||||
includeRefreshedModel = false
|
||||
const runtimeCommands = await runRpcExtensionCommands()
|
||||
assert.ok(runtimeCommands.commandNames.includes("commandcode-refresh"))
|
||||
assert.ok(runtimeCommands.commandNames.includes("commandcode-status"))
|
||||
assert.match(runtimeCommands.statusBefore, /source: live/)
|
||||
assert.match(runtimeCommands.refreshNotification, /3 models from live/)
|
||||
assert.match(runtimeCommands.statusAfter, /model count: 3/)
|
||||
assert.match(runtimeCommands.refreshNotification, /4 models from live/)
|
||||
assert.match(runtimeCommands.statusAfter, /model count: 4/)
|
||||
assert.doesNotMatch(
|
||||
`${runtimeCommands.statusBefore}\n${runtimeCommands.statusAfter}\n${runtimeCommands.stderr}`,
|
||||
/mock-key/,
|
||||
@@ -702,7 +771,7 @@ try {
|
||||
assert.equal(rpc.sawTextDelta, true)
|
||||
assert.equal(requestCount, 1)
|
||||
|
||||
console.log("[pi-local] reject image input through real RPC preflight/provider path")
|
||||
console.log("[pi-local] forward image input through the documented provider schema")
|
||||
requestCount = 0
|
||||
const imageRpc = await runRpcQuery(10_000, "describe image", [], {
|
||||
images: [
|
||||
@@ -713,14 +782,15 @@ try {
|
||||
},
|
||||
],
|
||||
})
|
||||
assert.equal(requestCount, 0)
|
||||
assert.equal(imageRpc.ok, true, imageRpc.stderr)
|
||||
assert.equal(requestCount, 1)
|
||||
const imageContent = lastRequestBody?.messages?.find(
|
||||
(message) => message.role === "user",
|
||||
)?.content
|
||||
assert.ok(Array.isArray(imageContent), JSON.stringify(lastRequestBody?.messages))
|
||||
assert.ok(
|
||||
imageRpc.events.some(
|
||||
(event) =>
|
||||
event.type === "message_end" &&
|
||||
event.message?.role === "assistant" &&
|
||||
event.message?.stopReason === "error",
|
||||
) || imageRpc.stderr.includes("does not support image"),
|
||||
imageContent.some((part) => part.type === "image_url"),
|
||||
JSON.stringify(imageContent),
|
||||
)
|
||||
|
||||
console.log("[pi-local] verify overflow normalization and compaction recovery")
|
||||
@@ -729,8 +799,7 @@ try {
|
||||
const overflowRpc = await runRpcOverflowRecovery()
|
||||
assert.equal(overflowRpc.ok, true)
|
||||
assert.ok(overflowRpc.requests >= 4)
|
||||
assert.equal(overflowRpc.sawNormalizedOverflow, true)
|
||||
assert.equal(overflowRpc.sawCompactionRetry, true)
|
||||
assert.equal(overflowRpc.sawCompactionRetry, true, JSON.stringify(overflowRpc))
|
||||
assert.equal(overflowRpc.stderrHasSecrets, false)
|
||||
overflowMode = false
|
||||
|
||||
|
||||
@@ -1,658 +0,0 @@
|
||||
/**
|
||||
* Unit tests for the real pure helpers exported by src/core.ts.
|
||||
* These are hermetic: no pi runtime and no network.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict"
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import {
|
||||
assertTextOnlyMessages,
|
||||
getApiKey,
|
||||
getEnvironmentInfo,
|
||||
mapFinishReason,
|
||||
messagesToCC,
|
||||
parseStreamEventLine,
|
||||
projectSlugFromPath,
|
||||
textContent,
|
||||
toJsonSchema,
|
||||
toolsToJson,
|
||||
} from "../src/core.ts"
|
||||
import { redactCommandCodeErrorText } from "../src/overflow.ts"
|
||||
|
||||
import { objectAt } from "./helpers.ts"
|
||||
|
||||
describe("getApiKey()", () => {
|
||||
it("uses COMMANDCODE_API_KEY from provided env", () => {
|
||||
assert.equal(getApiKey({ env: { COMMANDCODE_API_KEY: "env-key" }, authPaths: [] }), "env-key")
|
||||
})
|
||||
|
||||
it("reads apiKey, commandcode, pi OAuth, and official CLI credential fields", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cc-auth-"))
|
||||
try {
|
||||
const first = join(dir, "first.json")
|
||||
const second = join(dir, "second.json")
|
||||
const oauth = join(dir, "oauth.json")
|
||||
const official = join(dir, "official.json")
|
||||
writeFileSync(first, JSON.stringify({ apiKey: "file-key" }))
|
||||
writeFileSync(second, JSON.stringify({ commandcode: "fallback-key" }))
|
||||
writeFileSync(
|
||||
oauth,
|
||||
JSON.stringify({
|
||||
commandcode: {
|
||||
type: "oauth",
|
||||
access: "oauth-access-key",
|
||||
refresh: "oauth-refresh-key",
|
||||
expires: Date.now() + 3600000,
|
||||
},
|
||||
}),
|
||||
)
|
||||
writeFileSync(
|
||||
official,
|
||||
JSON.stringify({
|
||||
"command-code": {
|
||||
type: "api",
|
||||
key: "official-cli-key",
|
||||
},
|
||||
}),
|
||||
)
|
||||
assert.equal(getApiKey({ env: {}, authPaths: [first, second] }), "file-key")
|
||||
assert.equal(getApiKey({ env: {}, authPaths: [second] }), "fallback-key")
|
||||
assert.equal(getApiKey({ env: {}, authPaths: [oauth] }), "oauth-access-key")
|
||||
assert.equal(getApiKey({ env: {}, authPaths: [official] }), "official-cli-key")
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("ignores malformed auth files", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cc-auth-bad-"))
|
||||
try {
|
||||
const bad = join(dir, "bad.json")
|
||||
writeFileSync(bad, "not json")
|
||||
assert.equal(getApiKey({ env: {}, authPaths: [bad] }), undefined)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("uses injected homeDir for default auth paths", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cc-home-"))
|
||||
try {
|
||||
const authDir = join(dir, ".pi", "agent")
|
||||
mkdirSync(authDir, { recursive: true })
|
||||
writeFileSync(join(authDir, "auth.json"), JSON.stringify({ commandcode: "pi-key" }))
|
||||
assert.equal(getApiKey({ env: {}, homeDir: () => dir }), "pi-key")
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("error redaction", () => {
|
||||
it("redacts bearer, credential, and query-string secrets", () => {
|
||||
const redacted = redactCommandCodeErrorText(
|
||||
"Bearer user_secret_value api_key=user_secret_value https://example.test/x?token=user_secret_value",
|
||||
)
|
||||
assert.doesNotMatch(redacted, /user_secret_value/)
|
||||
assert.match(redacted, /Bearer \[redacted\]/)
|
||||
assert.doesNotMatch(
|
||||
redactCommandCodeErrorText("provider returned sk-test-secret-value-1234567890"),
|
||||
/sk-test-secret-value/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("projectSlugFromPath()", () => {
|
||||
it("matches the official CLI-style slug from an absolute working directory", () => {
|
||||
assert.equal(
|
||||
projectSlugFromPath("/Users/patwoz/dev/Personal/pi/pi-commandcode-provider"),
|
||||
"users-patwoz-dev-personal-pi-pi-commandcode-provider",
|
||||
)
|
||||
assert.equal(projectSlugFromPath("/repo"), "repo")
|
||||
})
|
||||
})
|
||||
|
||||
describe("text-only image handling", () => {
|
||||
it("rejects image content for models without image support", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
assertTextOnlyMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "image", data: "base64-data", mimeType: "image/png" }],
|
||||
},
|
||||
]),
|
||||
/does not support image content/i,
|
||||
)
|
||||
assert.throws(
|
||||
() =>
|
||||
assertTextOnlyMessages([
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "c1",
|
||||
content: [{ type: "image", data: "base64-data", mimeType: "image/png" }],
|
||||
},
|
||||
]),
|
||||
/does not support image content/i,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("textContent()", () => {
|
||||
it("extracts and joins text blocks", () => {
|
||||
assert.equal(
|
||||
textContent({
|
||||
content: [
|
||||
{ type: "text", text: "hello" },
|
||||
{ type: "text", text: "world" },
|
||||
],
|
||||
}),
|
||||
"hello\nworld",
|
||||
)
|
||||
})
|
||||
|
||||
it("extracts text while images are handled separately", () => {
|
||||
assert.equal(
|
||||
textContent({
|
||||
content: [
|
||||
{ type: "text", text: "hello" },
|
||||
{ type: "image", data: "x", mimeType: "image/png" },
|
||||
{ type: "text", text: "world" },
|
||||
],
|
||||
}),
|
||||
"hello\nworld",
|
||||
)
|
||||
})
|
||||
|
||||
it("handles empty or missing content", () => {
|
||||
assert.equal(textContent({ content: [] }), "")
|
||||
assert.equal(textContent({}), "")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getEnvironmentInfo()", () => {
|
||||
it("returns platform, arch, and Node version", () => {
|
||||
const info = getEnvironmentInfo()
|
||||
assert.match(info, /^(darwin|linux|win32)-/)
|
||||
assert.ok(info.includes("Node.js"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("toJsonSchema()", () => {
|
||||
it("converts scalar, enum, object, optional, array, and union schema shapes", () => {
|
||||
assert.deepEqual(toJsonSchema({ kind: "string" }), { type: "string" })
|
||||
assert.deepEqual(toJsonSchema({ kind: "Number" }), { type: "number" })
|
||||
assert.deepEqual(toJsonSchema({ kind: "boolean" }), { type: "boolean" })
|
||||
assert.deepEqual(toJsonSchema({ kind: "string", enum: ["left", "right"] }), {
|
||||
type: "string",
|
||||
enum: ["left", "right"],
|
||||
})
|
||||
assert.deepEqual(
|
||||
toJsonSchema({
|
||||
kind: "object",
|
||||
properties: {
|
||||
name: { kind: "string" },
|
||||
tags: { kind: "array", items: { kind: "string" }, optional: true },
|
||||
},
|
||||
}),
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
tags: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
)
|
||||
assert.deepEqual(toJsonSchema({ kind: "optional", wrapped: { kind: "string" } }), {
|
||||
type: "string",
|
||||
})
|
||||
assert.deepEqual(toJsonSchema({ kind: "union", variants: [{}, { kind: "number" }] }), {
|
||||
type: "number",
|
||||
})
|
||||
})
|
||||
|
||||
it("preserves explicit required arrays and handles unknown values", () => {
|
||||
assert.deepEqual(
|
||||
toJsonSchema({
|
||||
type: "object",
|
||||
properties: { name: { type: "string" }, nickname: { type: "string" } },
|
||||
required: ["name"],
|
||||
}),
|
||||
{
|
||||
type: "object",
|
||||
properties: { name: { type: "string" }, nickname: { type: "string" } },
|
||||
required: ["name"],
|
||||
},
|
||||
)
|
||||
assert.deepEqual(toJsonSchema(undefined), {})
|
||||
assert.deepEqual(toJsonSchema({ kind: "wat" }), {})
|
||||
assert.deepEqual(toJsonSchema({ type: "wat", description: "not a schema" }), {})
|
||||
assert.deepEqual(toJsonSchema({}), {})
|
||||
assert.equal(toJsonSchema(true), true)
|
||||
})
|
||||
|
||||
it("preserves complete JSON Schema metadata and nested schemas", () => {
|
||||
assert.deepEqual(
|
||||
toJsonSchema({
|
||||
type: "object",
|
||||
description: "Search options",
|
||||
properties: {
|
||||
query: {
|
||||
type: "string",
|
||||
description: "Text to search for",
|
||||
minLength: 2,
|
||||
maxLength: 50,
|
||||
pattern: "^[a-z]+$",
|
||||
default: "pi",
|
||||
},
|
||||
limit: {
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
exclusiveMinimum: 0,
|
||||
multipleOf: 1,
|
||||
default: 10,
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
minItems: 1,
|
||||
maxItems: 3,
|
||||
uniqueItems: true,
|
||||
items: {
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
required: ["name"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["query", "limit"],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
{
|
||||
type: "object",
|
||||
description: "Search options",
|
||||
properties: {
|
||||
query: {
|
||||
type: "string",
|
||||
description: "Text to search for",
|
||||
minLength: 2,
|
||||
maxLength: 50,
|
||||
pattern: "^[a-z]+$",
|
||||
default: "pi",
|
||||
},
|
||||
limit: {
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
exclusiveMinimum: 0,
|
||||
multipleOf: 1,
|
||||
default: 10,
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
minItems: 1,
|
||||
maxItems: 3,
|
||||
uniqueItems: true,
|
||||
items: {
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
required: ["name"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["query", "limit"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it("preserves JSON Schema composition and nullable forms", () => {
|
||||
assert.deepEqual(
|
||||
toJsonSchema({
|
||||
anyOf: [{ type: "string" }, { type: "number" }],
|
||||
oneOf: [{ const: "a" }, { const: "b" }],
|
||||
allOf: [{ minLength: 1 }, { maxLength: 10 }],
|
||||
nullable: true,
|
||||
}),
|
||||
{
|
||||
anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }],
|
||||
oneOf: [{ const: "a" }, { const: "b" }],
|
||||
allOf: [{ minLength: 1 }, { maxLength: 10 }],
|
||||
},
|
||||
)
|
||||
assert.deepEqual(toJsonSchema({ type: ["string", "null"] }), {
|
||||
type: ["string", "null"],
|
||||
})
|
||||
assert.deepEqual(toJsonSchema({ type: "string", nullable: true }), {
|
||||
type: ["string", "null"],
|
||||
})
|
||||
})
|
||||
|
||||
it("preserves dangerous schema property names", () => {
|
||||
const inputProperties: Record<string, unknown> = {
|
||||
constructor: { type: "number" },
|
||||
}
|
||||
Object.defineProperty(inputProperties, "__proto__", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: { type: "string" },
|
||||
writable: true,
|
||||
})
|
||||
const schema = toJsonSchema({
|
||||
type: "object",
|
||||
properties: inputProperties,
|
||||
required: ["__proto__", "constructor"],
|
||||
})
|
||||
assert.ok(schema && typeof schema === "object" && !Array.isArray(schema))
|
||||
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
||||
throw new Error("expected object schema")
|
||||
}
|
||||
const outputProperties: unknown = Object.getOwnPropertyDescriptor(schema, "properties")?.value
|
||||
assert.ok(outputProperties && typeof outputProperties === "object")
|
||||
if (!outputProperties || typeof outputProperties !== "object") {
|
||||
throw new Error("expected object properties")
|
||||
}
|
||||
assert.ok(Object.prototype.hasOwnProperty.call(outputProperties, "__proto__"))
|
||||
assert.deepEqual(Object.getOwnPropertyDescriptor(outputProperties, "__proto__")?.value, {
|
||||
type: "string",
|
||||
})
|
||||
assert.deepEqual(Object.getOwnPropertyDescriptor(outputProperties, "constructor")?.value, {
|
||||
type: "number",
|
||||
})
|
||||
})
|
||||
|
||||
it("converts legacy shapes without collapsing unions", () => {
|
||||
assert.deepEqual(
|
||||
toJsonSchema({
|
||||
kind: "Object",
|
||||
description: "Legacy options",
|
||||
properties: {
|
||||
mode: {
|
||||
kind: "union",
|
||||
variants: [
|
||||
{ kind: "string", enum: ["fast", "safe"] },
|
||||
{ kind: "string", enum: ["debug"] },
|
||||
],
|
||||
},
|
||||
count: { kind: "Number", minimum: 1, optional: true },
|
||||
nested: {
|
||||
kind: "Array",
|
||||
element: { kind: "object", properties: { value: { kind: "boolean" } } },
|
||||
},
|
||||
},
|
||||
optional: ["count"],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
{
|
||||
type: "object",
|
||||
description: "Legacy options",
|
||||
properties: {
|
||||
mode: {
|
||||
anyOf: [
|
||||
{ type: "string", enum: ["fast", "safe"] },
|
||||
{ type: "string", enum: ["debug"] },
|
||||
],
|
||||
},
|
||||
count: { type: "number", minimum: 1 },
|
||||
nested: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: { value: { type: "boolean" } },
|
||||
required: ["value"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["mode", "nested"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
)
|
||||
assert.deepEqual(
|
||||
toJsonSchema({
|
||||
kind: "intersect",
|
||||
variants: [{ kind: "object", properties: { a: { kind: "string" } } }, { kind: "number" }],
|
||||
}),
|
||||
{
|
||||
allOf: [
|
||||
{ type: "object", properties: { a: { type: "string" } }, required: ["a"] },
|
||||
{ type: "number" },
|
||||
],
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("toolsToJson()", () => {
|
||||
it("converts pi tools to Command Code tool JSON", () => {
|
||||
assert.deepEqual(
|
||||
toolsToJson([
|
||||
{
|
||||
name: "get_weather",
|
||||
description: "Get weather",
|
||||
parameters: {
|
||||
kind: "object",
|
||||
properties: { city: { kind: "string" } },
|
||||
},
|
||||
},
|
||||
]),
|
||||
[
|
||||
{
|
||||
type: "function",
|
||||
name: "get_weather",
|
||||
description: "Get weather",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: { city: { type: "string" } },
|
||||
required: ["city"],
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
it("returns an empty array for missing tools", () => {
|
||||
assert.deepEqual(toolsToJson(), [])
|
||||
})
|
||||
})
|
||||
|
||||
describe("messagesToCC()", () => {
|
||||
it("converts user, assistant, and tool result messages", () => {
|
||||
const result = messagesToCC([
|
||||
{ role: "user", content: "read /tmp/test" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "I will read" },
|
||||
{ type: "text", text: "Sure" },
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "c1",
|
||||
name: "read",
|
||||
arguments: { path: "/tmp/test" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "c1",
|
||||
toolName: "read",
|
||||
isError: false,
|
||||
content: [
|
||||
{ type: "text", text: "hello" },
|
||||
{ type: "text", text: "world" },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
assert.equal(objectAt(result, ["0", "role"]), "user")
|
||||
assert.equal(objectAt(result, ["1", "role"]), "assistant")
|
||||
assert.equal(objectAt(result, ["1", "content", "0", "type"]), "text")
|
||||
assert.equal(objectAt(result, ["1", "content", "1", "type"]), "tool-call")
|
||||
assert.equal(objectAt(result, ["1", "content", "2"]), undefined)
|
||||
assert.equal(objectAt(result, ["2", "role"]), "tool")
|
||||
assert.equal(objectAt(result, ["2", "content", "0", "output", "value"]), "hello\nworld")
|
||||
})
|
||||
|
||||
it("serializes image inputs in the current Command Code wire format", () => {
|
||||
assert.deepEqual(
|
||||
messagesToCC(
|
||||
[
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "inspect this" },
|
||||
{ type: "image", data: "aGVsbG8=", mimeType: "image/png" },
|
||||
],
|
||||
},
|
||||
],
|
||||
{ allowImages: true },
|
||||
),
|
||||
[
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "inspect this" },
|
||||
{
|
||||
type: "image",
|
||||
image: "data:image/png;base64,aGVsbG8=",
|
||||
mimeType: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
it("preserves tool-result images as a following user image message", () => {
|
||||
const result = messagesToCC(
|
||||
[
|
||||
{ role: "user", content: "read image" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "c1", name: "read", arguments: {} }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "c1",
|
||||
toolName: "read",
|
||||
content: [
|
||||
{ type: "text", text: "image attached" },
|
||||
{ type: "image", data: "aGVsbG8=", mimeType: "image/jpeg" },
|
||||
],
|
||||
},
|
||||
],
|
||||
{ allowImages: true },
|
||||
)
|
||||
|
||||
assert.equal(objectAt(result, ["2", "role"]), "tool")
|
||||
assert.equal(objectAt(result, ["2", "content", "0", "output", "value"]), "image attached")
|
||||
assert.deepEqual(objectAt(result, ["3"]), {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
image: "data:image/jpeg;base64,aGVsbG8=",
|
||||
mimeType: "image/jpeg",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("drops previous assistant reasoning while preserving text and tool calls", () => {
|
||||
const result = messagesToCC([
|
||||
{ role: "user", content: "first question" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "private reasoning from turn one" },
|
||||
{ type: "text", text: "first answer" },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "follow-up question" },
|
||||
])
|
||||
|
||||
assert.deepEqual(result, [
|
||||
{ role: "user", content: "first question" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "first answer" }] },
|
||||
{ role: "user", content: "follow-up question" },
|
||||
])
|
||||
})
|
||||
|
||||
it("omits assistant turns that contain only previous reasoning", () => {
|
||||
const result = messagesToCC([
|
||||
{ role: "user", content: "first question" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "private reasoning" }],
|
||||
},
|
||||
{ role: "user", content: "follow-up question" },
|
||||
])
|
||||
|
||||
assert.deepEqual(result, [
|
||||
{ role: "user", content: "first question" },
|
||||
{ role: "user", content: "follow-up question" },
|
||||
])
|
||||
})
|
||||
|
||||
it("drops orphaned tool calls that have no matching tool result", () => {
|
||||
const result = messagesToCC([
|
||||
{ role: "user", content: "edit a file" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "I will edit it" },
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "missing-result",
|
||||
name: "edit",
|
||||
arguments: { path: "x" },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
assert.equal(objectAt(result, ["1", "role"]), "assistant")
|
||||
assert.equal(objectAt(result, ["1", "content", "0", "type"]), "text")
|
||||
assert.equal(objectAt(result, ["1", "content", "1"]), undefined)
|
||||
})
|
||||
|
||||
it("handles empty conversations", () => {
|
||||
assert.deepEqual(messagesToCC([]), [])
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseStreamEventLine()", () => {
|
||||
it("parses plain JSON and SSE data lines", () => {
|
||||
assert.deepEqual(parseStreamEventLine('{"type":"text-delta","text":"x"}'), {
|
||||
type: "text-delta",
|
||||
text: "x",
|
||||
})
|
||||
assert.deepEqual(parseStreamEventLine('data: {"type":"finish","finishReason":"stop"}'), {
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
})
|
||||
})
|
||||
|
||||
it("ignores comments, event labels, done markers, and malformed JSON", () => {
|
||||
assert.equal(parseStreamEventLine(":"), undefined)
|
||||
assert.equal(parseStreamEventLine("event: message"), undefined)
|
||||
assert.equal(parseStreamEventLine("data: [DONE]"), undefined)
|
||||
assert.equal(parseStreamEventLine("not-json"), undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("mapFinishReason()", () => {
|
||||
it("maps provider finish reasons to pi stop reasons", () => {
|
||||
assert.equal(mapFinishReason("stop"), "stop")
|
||||
assert.equal(mapFinishReason("tool-calls"), "toolUse")
|
||||
assert.equal(mapFinishReason("max_tokens"), "length")
|
||||
assert.equal(mapFinishReason("max_output_tokens"), "length")
|
||||
})
|
||||
})
|
||||
@@ -1,470 +0,0 @@
|
||||
/**
|
||||
* Tests for retry and timeout behaviour driven by pi settings.json
|
||||
* retry config (timeoutMs, maxRetries, maxRetryDelayMs).
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict"
|
||||
import { after, before, beforeEach, describe, it } from "node:test"
|
||||
|
||||
import type { AssistantMessageEvent } from "../src/core.ts"
|
||||
import {
|
||||
collectEvents,
|
||||
createTestDeps,
|
||||
makeContext,
|
||||
makeModel,
|
||||
startMockCommandCodeServer,
|
||||
type MockCommandCodeServer,
|
||||
} from "./helpers.ts"
|
||||
|
||||
const TEST_API_KEY = "option-key"
|
||||
|
||||
let server: MockCommandCodeServer
|
||||
|
||||
before(async () => {
|
||||
server = await startMockCommandCodeServer()
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await server.close()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
server.reset()
|
||||
})
|
||||
|
||||
function eventTypes(events: readonly AssistantMessageEvent[]): string[] {
|
||||
return events.map((event) => event.type)
|
||||
}
|
||||
|
||||
describe("streamCommandCode — retry on transient errors", () => {
|
||||
it("retries on 429 and succeeds on the second attempt", async () => {
|
||||
server.mockResponseQueue([
|
||||
{ type: "error", status: 429, body: "rate limited" },
|
||||
{
|
||||
type: "success",
|
||||
events: [
|
||||
JSON.stringify({ type: "text-delta", text: "ok" }),
|
||||
JSON.stringify({ type: "finish", finishReason: "stop" }),
|
||||
],
|
||||
},
|
||||
])
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), {
|
||||
apiKey: TEST_API_KEY,
|
||||
maxRetries: 2,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.equal(server.requestCount(), 2)
|
||||
assert.deepEqual(eventTypes(events), ["start", "text_start", "text_delta", "text_end", "done"])
|
||||
const done = events.at(-1)
|
||||
if (done?.type !== "done") throw new Error("expected done")
|
||||
assert.equal(done.reason, "stop")
|
||||
})
|
||||
|
||||
it("retries on 500 and succeeds on the second attempt", async () => {
|
||||
server.mockResponseQueue([
|
||||
{ type: "error", status: 500, body: "internal server error" },
|
||||
{
|
||||
type: "success",
|
||||
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
|
||||
},
|
||||
])
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), {
|
||||
apiKey: TEST_API_KEY,
|
||||
maxRetries: 2,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.equal(server.requestCount(), 2)
|
||||
assert.equal(events.at(-1)?.type, "done")
|
||||
})
|
||||
|
||||
it("does NOT retry on 400 (non-retryable client error)", async () => {
|
||||
server.mockResponse({ type: "error", status: 400, body: "bad request" })
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), { apiKey: TEST_API_KEY }),
|
||||
)
|
||||
|
||||
assert.equal(server.requestCount(), 1)
|
||||
assert.deepEqual(eventTypes(events), ["start", "error"])
|
||||
const last = events.at(-1)
|
||||
if (last?.type !== "error") throw new Error("expected error")
|
||||
assert.match(last.error.errorMessage ?? "", /400/)
|
||||
})
|
||||
|
||||
it("exhausts maxRetries and emits an error", async () => {
|
||||
server.mockResponse({ type: "error", status: 503, body: "unavailable" })
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), {
|
||||
apiKey: TEST_API_KEY,
|
||||
maxRetries: 3,
|
||||
}),
|
||||
)
|
||||
|
||||
// initial attempt + 3 retries = 4 total
|
||||
assert.equal(server.requestCount(), 4)
|
||||
assert.deepEqual(eventTypes(events), ["start", "error"])
|
||||
const last503 = events.at(-1)
|
||||
if (last503?.type !== "error") throw new Error("expected error")
|
||||
assert.match(last503.error.errorMessage ?? "", /503/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("streamCommandCode — Retry-After header", () => {
|
||||
it("respects Retry-After delay in seconds", async () => {
|
||||
let delayCalled = false
|
||||
server.mockResponseQueue([
|
||||
{
|
||||
type: "error",
|
||||
status: 429,
|
||||
body: "rate limited",
|
||||
headers: { "retry-after": "2" },
|
||||
},
|
||||
{
|
||||
type: "success",
|
||||
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
|
||||
},
|
||||
])
|
||||
const { streamCommandCode } = createTestDeps({
|
||||
apiBase: server.baseUrl(),
|
||||
delay: async (ms: number) => {
|
||||
delayCalled = true
|
||||
assert.equal(ms, 2000)
|
||||
},
|
||||
})
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), {
|
||||
apiKey: TEST_API_KEY,
|
||||
maxRetries: 2,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.equal(server.requestCount(), 2)
|
||||
assert.equal(events.at(-1)?.type, "done")
|
||||
assert.ok(delayCalled, "delay should have been called with Retry-After value")
|
||||
})
|
||||
|
||||
it("fails immediately when Retry-After exceeds maxRetryDelayMs", async () => {
|
||||
server.mockResponse({
|
||||
type: "error",
|
||||
status: 429,
|
||||
body: "rate limited",
|
||||
headers: { "retry-after": "300" },
|
||||
})
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), {
|
||||
apiKey: TEST_API_KEY,
|
||||
maxRetryDelayMs: 10_000,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.equal(server.requestCount(), 1)
|
||||
assert.deepEqual(eventTypes(events), ["start", "error"])
|
||||
const lastMax = events.at(-1)
|
||||
if (lastMax?.type !== "error") throw new Error("expected error")
|
||||
assert.match(lastMax.error.errorMessage ?? "", /exceeds max/)
|
||||
})
|
||||
|
||||
it("does not cap Retry-After when maxRetryDelayMs is 0", async () => {
|
||||
let delayCalled = false
|
||||
server.mockResponseQueue([
|
||||
{
|
||||
type: "error",
|
||||
status: 429,
|
||||
body: "rate limited",
|
||||
headers: { "retry-after": "120" },
|
||||
},
|
||||
{
|
||||
type: "success",
|
||||
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
|
||||
},
|
||||
])
|
||||
const { streamCommandCode } = createTestDeps({
|
||||
apiBase: server.baseUrl(),
|
||||
delay: async (ms: number) => {
|
||||
delayCalled = true
|
||||
assert.equal(ms, 120_000)
|
||||
},
|
||||
})
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), {
|
||||
apiKey: TEST_API_KEY,
|
||||
maxRetries: 1,
|
||||
maxRetryDelayMs: 0,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.equal(server.requestCount(), 2)
|
||||
assert.equal(events.at(-1)?.type, "done")
|
||||
assert.ok(delayCalled)
|
||||
})
|
||||
})
|
||||
|
||||
describe("streamCommandCode — timeout", () => {
|
||||
it("retries on per-attempt timeout and succeeds", async () => {
|
||||
server.mockResponseQueue([
|
||||
{
|
||||
type: "success",
|
||||
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
|
||||
hangAfterLast: true,
|
||||
responseDelay: 200,
|
||||
},
|
||||
{
|
||||
type: "success",
|
||||
events: [
|
||||
JSON.stringify({ type: "text-delta", text: "fast" }),
|
||||
JSON.stringify({ type: "finish", finishReason: "stop" }),
|
||||
],
|
||||
},
|
||||
])
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), {
|
||||
apiKey: TEST_API_KEY,
|
||||
timeoutMs: 50,
|
||||
maxRetries: 2,
|
||||
}),
|
||||
5_000,
|
||||
)
|
||||
|
||||
assert.equal(server.requestCount(), 2)
|
||||
assert.deepEqual(eventTypes(events), ["start", "text_start", "text_delta", "text_end", "done"])
|
||||
})
|
||||
|
||||
it("retries when the response starts but the stream hangs before finish", async () => {
|
||||
server.mockResponseQueue([
|
||||
{
|
||||
type: "success",
|
||||
events: [],
|
||||
hangAfterLast: true,
|
||||
},
|
||||
{
|
||||
type: "success",
|
||||
events: [
|
||||
JSON.stringify({ type: "text-delta", text: "ok" }),
|
||||
JSON.stringify({ type: "finish", finishReason: "stop" }),
|
||||
],
|
||||
},
|
||||
])
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), {
|
||||
apiKey: TEST_API_KEY,
|
||||
timeoutMs: 50,
|
||||
maxRetries: 2,
|
||||
}),
|
||||
5_000,
|
||||
)
|
||||
|
||||
assert.equal(server.requestCount(), 2)
|
||||
assert.deepEqual(eventTypes(events), ["start", "text_start", "text_delta", "text_end", "done"])
|
||||
})
|
||||
|
||||
it("does NOT retry on timeout after partial text-delta was emitted", async () => {
|
||||
server.mockResponse({
|
||||
type: "success",
|
||||
events: [JSON.stringify({ type: "text-delta", text: "partial" })],
|
||||
hangAfterLast: true,
|
||||
})
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), {
|
||||
apiKey: TEST_API_KEY,
|
||||
timeoutMs: 50,
|
||||
maxRetries: 2,
|
||||
}),
|
||||
5_000,
|
||||
)
|
||||
|
||||
assert.equal(server.requestCount(), 1)
|
||||
assert.deepEqual(eventTypes(events), ["start", "text_start", "text_delta", "error"])
|
||||
})
|
||||
|
||||
it("emits error when all retry attempts time out", async () => {
|
||||
server.mockResponse({
|
||||
type: "success",
|
||||
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
|
||||
hangAfterLast: true,
|
||||
responseDelay: 200,
|
||||
})
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), {
|
||||
apiKey: TEST_API_KEY,
|
||||
timeoutMs: 50,
|
||||
maxRetries: 1,
|
||||
}),
|
||||
5_000,
|
||||
)
|
||||
|
||||
// initial + 1 retry = 2
|
||||
assert.equal(server.requestCount(), 2)
|
||||
assert.deepEqual(eventTypes(events), ["start", "error"])
|
||||
const error = events.at(-1)
|
||||
if (error?.type !== "error") throw new Error("expected error")
|
||||
assert.match(error.error.errorMessage ?? "", /timed out after 50ms/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("streamCommandCode — abort cancels retry loop", () => {
|
||||
it("user abort stops retries immediately", async () => {
|
||||
server.mockResponse({ type: "error", status: 500, body: "error" })
|
||||
const controller = new AbortController()
|
||||
const { streamCommandCode } = createTestDeps({
|
||||
apiBase: server.baseUrl(),
|
||||
delay: async (_ms: number, signal: AbortSignal) => {
|
||||
// Abort during the retry delay
|
||||
controller.abort()
|
||||
// Simulate the real delay which rejects on abort
|
||||
return new Promise<void>((_, reject) => {
|
||||
if (signal.aborted) reject(new DOMException("Aborted", "AbortError"))
|
||||
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), {
|
||||
apiKey: TEST_API_KEY,
|
||||
signal: controller.signal,
|
||||
maxRetries: 10,
|
||||
}),
|
||||
)
|
||||
|
||||
// Should only have made 1 request (the initial one), then aborted during delay
|
||||
assert.equal(server.requestCount(), 1)
|
||||
assert.deepEqual(eventTypes(events), ["start", "error"])
|
||||
const error = events.at(-1)
|
||||
if (error?.type !== "error") throw new Error("expected error")
|
||||
assert.equal(error.reason, "aborted")
|
||||
})
|
||||
})
|
||||
|
||||
describe("streamCommandCode — retry defaults", () => {
|
||||
it("uses default maxRetries of 0 when not specified", async () => {
|
||||
server.mockResponse({ type: "error", status: 500, body: "error" })
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
await collectEvents(streamCommandCode(makeModel(), makeContext(), { apiKey: TEST_API_KEY }))
|
||||
|
||||
assert.equal(server.requestCount(), 1)
|
||||
})
|
||||
|
||||
it("respects maxRetries: 0 (no retries)", async () => {
|
||||
server.mockResponse({ type: "error", status: 500, body: "error" })
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), {
|
||||
apiKey: TEST_API_KEY,
|
||||
maxRetries: 0,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.equal(server.requestCount(), 1)
|
||||
assert.deepEqual(eventTypes(events), ["start", "error"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("streamCommandCode — stream-level error retry", () => {
|
||||
it("retries when API returns 200 OK but stream contains an error event", async () => {
|
||||
server.mockResponseQueue([
|
||||
{
|
||||
type: "success",
|
||||
events: [
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: "Service temporarily unavailable. Please try again shortly.",
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "success",
|
||||
events: [
|
||||
JSON.stringify({ type: "text-delta", text: "ok" }),
|
||||
JSON.stringify({ type: "finish", finishReason: "stop" }),
|
||||
],
|
||||
},
|
||||
])
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), {
|
||||
apiKey: TEST_API_KEY,
|
||||
maxRetries: 2,
|
||||
}),
|
||||
)
|
||||
|
||||
assert.equal(server.requestCount(), 2)
|
||||
assert.deepEqual(eventTypes(events), ["start", "text_start", "text_delta", "text_end", "done"])
|
||||
})
|
||||
|
||||
it("exhausts retries on persistent stream-level errors", async () => {
|
||||
server.mockResponse({
|
||||
type: "success",
|
||||
events: [
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: "Service temporarily unavailable. Please try again shortly.",
|
||||
}),
|
||||
],
|
||||
})
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), {
|
||||
apiKey: TEST_API_KEY,
|
||||
maxRetries: 3,
|
||||
}),
|
||||
)
|
||||
|
||||
// initial + 3 retries = 4
|
||||
assert.equal(server.requestCount(), 4)
|
||||
assert.deepEqual(eventTypes(events), ["start", "error"])
|
||||
const last = events.at(-1)
|
||||
if (last?.type !== "error") throw new Error("expected error")
|
||||
assert.match(last.error.errorMessage ?? "", /temporarily unavailable/)
|
||||
})
|
||||
|
||||
it("does NOT retry stream error when content was already emitted", async () => {
|
||||
server.mockResponseQueue([
|
||||
{
|
||||
type: "success",
|
||||
events: [
|
||||
JSON.stringify({ type: "text-delta", text: "partial" }),
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: "Service temporarily unavailable",
|
||||
}),
|
||||
],
|
||||
},
|
||||
])
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), { apiKey: TEST_API_KEY }),
|
||||
)
|
||||
|
||||
// Only 1 request — no retry because content was already emitted.
|
||||
assert.equal(server.requestCount(), 1)
|
||||
assert.deepEqual(eventTypes(events), ["start", "text_start", "text_delta", "error"])
|
||||
})
|
||||
})
|
||||
@@ -49,6 +49,7 @@ class CommandContext implements CommandCodeCommandContext {
|
||||
const FIRST_MODEL: CommandCodeModel = {
|
||||
id: "first-model",
|
||||
name: "First Model",
|
||||
api: "openai-completions",
|
||||
reasoning: true,
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 16_384,
|
||||
@@ -57,6 +58,7 @@ const FIRST_MODEL: CommandCodeModel = {
|
||||
const SECOND_MODEL: CommandCodeModel = {
|
||||
id: "second-model",
|
||||
name: "Second Model",
|
||||
api: "openai-completions",
|
||||
reasoning: true,
|
||||
contextWindow: 256_000,
|
||||
maxTokens: 32_768,
|
||||
|
||||
@@ -1,771 +0,0 @@
|
||||
/**
|
||||
* Integration tests for the real streamCommandCode core using a local mock
|
||||
* Command Code server. No real API key or pi runtime required.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict"
|
||||
import { after, before, beforeEach, describe, it } from "node:test"
|
||||
|
||||
import type { AssistantMessageEvent } from "../src/core.ts"
|
||||
import { MODEL_EFFORTS, thinkingLevelMapForEfforts } from "../src/models.ts"
|
||||
import {
|
||||
collectEvents,
|
||||
createTestDeps,
|
||||
makeContext,
|
||||
makeModel,
|
||||
objectAt,
|
||||
startMockCommandCodeServer,
|
||||
type MockCommandCodeServer,
|
||||
} from "./helpers.ts"
|
||||
|
||||
let server: MockCommandCodeServer
|
||||
|
||||
before(async () => {
|
||||
server = await startMockCommandCodeServer()
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await server.close()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
server.reset()
|
||||
})
|
||||
|
||||
function eventTypes(events: readonly AssistantMessageEvent[]): string[] {
|
||||
return events.map((event) => event.type)
|
||||
}
|
||||
|
||||
describe("streamCommandCode — auth", () => {
|
||||
it("emits a missing-key error without touching the network", async () => {
|
||||
const { streamCommandCode } = createTestDeps({
|
||||
apiBase: server.baseUrl(),
|
||||
env: {},
|
||||
authPaths: [],
|
||||
})
|
||||
const stream = streamCommandCode(makeModel(), makeContext(), {
|
||||
apiKey: "",
|
||||
})
|
||||
const events = await collectEvents(stream)
|
||||
|
||||
assert.deepEqual(eventTypes(events), ["error"])
|
||||
assert.equal(events[0].type, "error")
|
||||
assert.equal(events[0].reason, "error")
|
||||
assert.match(events[0].error.errorMessage ?? "", /No Command Code API key/)
|
||||
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",
|
||||
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: "option-key" }))
|
||||
|
||||
assert.equal(server.lastRequestHeaders().authorization, "Bearer option-key")
|
||||
})
|
||||
})
|
||||
|
||||
describe("streamCommandCode — successful streams", () => {
|
||||
it("emits start → text events → done and accumulates usage", async () => {
|
||||
server.mockResponse({
|
||||
type: "success",
|
||||
events: [
|
||||
JSON.stringify({ type: "text-delta", text: "Hel" }),
|
||||
JSON.stringify({ type: "text-delta", text: "lo" }),
|
||||
JSON.stringify({
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
totalUsage: {
|
||||
inputTokens: 3124,
|
||||
outputTokens: 15,
|
||||
inputTokenDetails: { noCacheTokens: 52, cacheReadTokens: 3072 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
const { streamCommandCode, calculatedUsages } = createTestDeps({
|
||||
apiBase: server.baseUrl(),
|
||||
})
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
||||
)
|
||||
|
||||
assert.deepEqual(eventTypes(events), [
|
||||
"start",
|
||||
"text_start",
|
||||
"text_delta",
|
||||
"text_delta",
|
||||
"text_end",
|
||||
"done",
|
||||
])
|
||||
const done = events.at(-1)
|
||||
assert.equal(done?.type, "done")
|
||||
if (done?.type !== "done") throw new Error("expected done")
|
||||
assert.equal(done.reason, "stop")
|
||||
assert.equal(done.message.content[0]?.type, "text")
|
||||
assert.equal(
|
||||
done.message.content[0]?.type === "text" ? done.message.content[0].text : "",
|
||||
"Hello",
|
||||
)
|
||||
assert.equal(done.message.usage.input, 52)
|
||||
assert.equal(done.message.usage.cacheRead, 3072)
|
||||
assert.equal(done.message.usage.cacheWrite, 0)
|
||||
assert.equal(done.message.usage.totalTokens, 3139)
|
||||
assert.equal(calculatedUsages.length, 1)
|
||||
})
|
||||
|
||||
it("sends images for vision-capable models", async () => {
|
||||
server.mockResponse({
|
||||
type: "success",
|
||||
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
|
||||
})
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
await collectEvents(
|
||||
streamCommandCode(
|
||||
makeModel({ id: "gpt-5.6-luna" }),
|
||||
makeContext({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "inspect" },
|
||||
{ type: "image", data: "aGVsbG8=", mimeType: "image/png" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ apiKey: "mock-key" },
|
||||
),
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
objectAt(server.lastRequestBody(), ["params", "messages", "0", "content", "1", "image"]),
|
||||
"data:image/png;base64,aGVsbG8=",
|
||||
)
|
||||
})
|
||||
|
||||
it("rejects images before network access for text-only models", async () => {
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(
|
||||
makeModel({ id: "deepseek/deepseek-v4-pro" }),
|
||||
makeContext({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "image", data: "aGVsbG8=", mimeType: "image/png" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ apiKey: "mock-key" },
|
||||
),
|
||||
)
|
||||
|
||||
assert.equal(events.at(-1)?.type, "error")
|
||||
assert.equal(server.requestCount(), 0)
|
||||
})
|
||||
|
||||
it("derives uncached input when noCacheTokens is missing", async () => {
|
||||
server.mockResponse({
|
||||
type: "success",
|
||||
events: [
|
||||
JSON.stringify({
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
totalUsage: {
|
||||
inputTokens: 100,
|
||||
outputTokens: 10,
|
||||
inputTokenDetails: { cacheReadTokens: 75 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
||||
)
|
||||
|
||||
const done = events.at(-1)
|
||||
assert.equal(done?.type, "done")
|
||||
if (done?.type !== "done") throw new Error("expected done")
|
||||
assert.equal(done.message.usage.input, 25)
|
||||
assert.equal(done.message.usage.cacheRead, 75)
|
||||
assert.equal(done.message.usage.cacheWrite, 0)
|
||||
assert.equal(done.message.usage.totalTokens, 110)
|
||||
})
|
||||
|
||||
it("accounts for cache writes separately from uncached input", async () => {
|
||||
server.mockResponse({
|
||||
type: "success",
|
||||
events: [
|
||||
JSON.stringify({
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
totalUsage: {
|
||||
inputTokens: 100,
|
||||
outputTokens: 10,
|
||||
inputTokenDetails: {
|
||||
noCacheTokens: 20,
|
||||
cacheReadTokens: 70,
|
||||
cacheWriteTokens: 10,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
||||
)
|
||||
|
||||
const done = events.at(-1)
|
||||
assert.equal(done?.type, "done")
|
||||
if (done?.type !== "done") throw new Error("expected done")
|
||||
assert.equal(done.message.usage.input, 20)
|
||||
assert.equal(done.message.usage.cacheRead, 70)
|
||||
assert.equal(done.message.usage.cacheWrite, 10)
|
||||
assert.equal(done.message.usage.totalTokens, 110)
|
||||
})
|
||||
|
||||
it("ends on finish without waiting for an open upstream connection", async () => {
|
||||
server.mockResponse({
|
||||
type: "success",
|
||||
events: [
|
||||
JSON.stringify({ type: "text-delta", text: "done" }),
|
||||
JSON.stringify({ type: "finish", finishReason: "stop" }),
|
||||
],
|
||||
hangAfterLast: true,
|
||||
})
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
||||
500,
|
||||
)
|
||||
|
||||
assert.equal(events.at(-1)?.type, "done")
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
assert.ok(server.responseClosedBeforeEnd(), "client should cancel the still-open response body")
|
||||
})
|
||||
|
||||
it("emits reasoning and tool-call blocks in order", async () => {
|
||||
server.mockResponse({
|
||||
type: "success",
|
||||
events: [
|
||||
JSON.stringify({ type: "reasoning-start" }),
|
||||
JSON.stringify({ type: "reasoning-delta", text: "think" }),
|
||||
JSON.stringify({ type: "reasoning-end" }),
|
||||
JSON.stringify({ type: "text-delta", text: "Using tool" }),
|
||||
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",
|
||||
"text_start",
|
||||
"text_delta",
|
||||
"text_end",
|
||||
"toolcall_start",
|
||||
"toolcall_end",
|
||||
"done",
|
||||
])
|
||||
const done = events.at(-1)
|
||||
if (done?.type !== "done") throw new Error("expected done")
|
||||
assert.equal(done.reason, "toolUse")
|
||||
assert.deepEqual(
|
||||
done.message.content.map((content) => content.type),
|
||||
["thinking", "text", "toolCall"],
|
||||
)
|
||||
const toolCall = done.message.content[2]
|
||||
assert.equal(toolCall?.type === "toolCall" ? toolCall.name : "", "read_file")
|
||||
})
|
||||
|
||||
it("flushes reasoning if finish arrives without reasoning-end", async () => {
|
||||
server.mockResponse({
|
||||
type: "success",
|
||||
events: [
|
||||
JSON.stringify({ type: "reasoning-delta", text: "unfinished thought" }),
|
||||
JSON.stringify({ type: "finish", finishReason: "stop" }),
|
||||
],
|
||||
})
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
||||
)
|
||||
|
||||
const done = events.at(-1)
|
||||
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", () => {
|
||||
it("rejects image input before sending a lossy request", async () => {
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(
|
||||
makeModel(),
|
||||
makeContext({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "image", data: "base64-data", mimeType: "image/png" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ apiKey: "mock-key" },
|
||||
),
|
||||
)
|
||||
|
||||
assert.deepEqual(eventTypes(events), ["start", "error"])
|
||||
const lastEvent = events.at(-1)
|
||||
assert.equal(lastEvent?.type, "error")
|
||||
if (lastEvent?.type === "error") {
|
||||
assert.match(lastEvent.error.errorMessage ?? "", /does not support image content/i)
|
||||
}
|
||||
assert.equal(server.requestCount(), 0)
|
||||
})
|
||||
it("sends the expected request body and default headers", async () => {
|
||||
server.mockResponse({
|
||||
type: "success",
|
||||
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
|
||||
})
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
const context = makeContext({
|
||||
messages: [
|
||||
{ role: "user", content: "first" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "first response" }],
|
||||
},
|
||||
{ role: "user", content: "second" },
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
name: "get_weather",
|
||||
description: "Get weather",
|
||||
parameters: {
|
||||
kind: "object",
|
||||
properties: { city: { kind: "string" } },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await collectEvents(
|
||||
streamCommandCode(makeModel(), context, {
|
||||
apiKey: "mock-key",
|
||||
maxTokens: 500,
|
||||
}),
|
||||
)
|
||||
|
||||
const body = server.lastRequestBody()
|
||||
assert.equal(objectAt(body, ["config", "workingDir"]), "/repo")
|
||||
assert.equal(objectAt(body, ["config", "date"]), "2026-05-05")
|
||||
assert.equal(objectAt(body, ["params", "model"]), "deepseek/deepseek-v4-flash")
|
||||
assert.equal(objectAt(body, ["params", "stream"]), true)
|
||||
assert.equal(objectAt(body, ["params", "max_tokens"]), 500)
|
||||
assert.equal(objectAt(body, ["params", "reasoning_effort"]), undefined)
|
||||
assert.equal(objectAt(body, ["params", "temperature"]), 0.3)
|
||||
assert.equal(objectAt(body, ["params", "system"]), "You are a test assistant.")
|
||||
assert.equal(objectAt(body, ["memory"]), null)
|
||||
assert.equal(objectAt(body, ["taste"]), null)
|
||||
assert.equal(objectAt(body, ["skills"]), null)
|
||||
assert.equal(objectAt(body, ["permissionMode"]), undefined)
|
||||
assert.equal(objectAt(body, ["threadId"]), "00000000-0000-4000-8000-000000000000")
|
||||
assert.equal(
|
||||
objectAt(body, ["params", "messages", "1", "content", "0", "text"]),
|
||||
"first response",
|
||||
)
|
||||
assert.equal(objectAt(body, ["params", "tools", "0", "name"]), "get_weather")
|
||||
|
||||
const headers = server.lastRequestHeaders()
|
||||
assert.equal(headers.authorization, "Bearer mock-key")
|
||||
assert.equal(headers["x-command-code-version"], "1.15.1")
|
||||
assert.equal(headers["x-project-slug"], "repo")
|
||||
assert.equal(headers["x-taste-learning"], "true")
|
||||
assert.equal(headers["x-co-flag"], "false")
|
||||
assert.equal(headers["x-session-id"], undefined)
|
||||
})
|
||||
|
||||
it("accepts the legacy OMP nested reasoning map", async () => {
|
||||
server.mockResponse({
|
||||
type: "success",
|
||||
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
|
||||
})
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
const model = makeModel({
|
||||
id: "omp-compat-reasoning-model",
|
||||
reasoning: true,
|
||||
thinking: { effortMap: { high: "legacy-high" } },
|
||||
})
|
||||
|
||||
await collectEvents(
|
||||
streamCommandCode(model, makeContext(), { apiKey: "mock-key", reasoning: "high" }),
|
||||
)
|
||||
|
||||
assert.equal(objectAt(server.lastRequestBody(), ["params", "reasoning_effort"]), "legacy-high")
|
||||
})
|
||||
|
||||
it("forwards a supported Pi reasoning level as reasoning_effort", async () => {
|
||||
server.mockResponse({
|
||||
type: "success",
|
||||
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
|
||||
})
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
const model = makeModel({
|
||||
id: "deepseek/deepseek-v4-flash",
|
||||
reasoning: true,
|
||||
thinkingLevelMap: thinkingLevelMapForEfforts(MODEL_EFFORTS["deepseek/deepseek-v4-flash"]),
|
||||
})
|
||||
|
||||
await collectEvents(
|
||||
streamCommandCode(model, makeContext(), { apiKey: "mock-key", reasoning: "max" }),
|
||||
)
|
||||
|
||||
assert.equal(objectAt(server.lastRequestBody(), ["params", "reasoning_effort"]), "max")
|
||||
})
|
||||
|
||||
it("omits reasoning_effort for off, unsupported, and unknown reasoning levels", async () => {
|
||||
const model = makeModel({
|
||||
id: "deepseek/deepseek-v4-flash",
|
||||
reasoning: true,
|
||||
thinkingLevelMap: thinkingLevelMapForEfforts(MODEL_EFFORTS["deepseek/deepseek-v4-flash"]),
|
||||
})
|
||||
|
||||
for (const reasoning of ["off", "low"] as const) {
|
||||
server.mockResponse({
|
||||
type: "success",
|
||||
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
|
||||
})
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
await collectEvents(
|
||||
streamCommandCode(model, makeContext(), { apiKey: "mock-key", reasoning }),
|
||||
)
|
||||
assert.equal(
|
||||
objectAt(server.lastRequestBody(), ["params", "reasoning_effort"]),
|
||||
undefined,
|
||||
`${reasoning} should not be sent when it has no supported Command Code field`,
|
||||
)
|
||||
server.reset()
|
||||
}
|
||||
|
||||
server.mockResponse({
|
||||
type: "success",
|
||||
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
|
||||
})
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
await collectEvents(
|
||||
streamCommandCode(
|
||||
makeModel({ id: "new-model-without-metadata", reasoning: false }),
|
||||
makeContext(),
|
||||
{ apiKey: "mock-key", reasoning: "high" },
|
||||
),
|
||||
)
|
||||
assert.equal(objectAt(server.lastRequestBody(), ["params", "reasoning_effort"]), undefined)
|
||||
})
|
||||
|
||||
it("caps maxTokens and passes custom headers", async () => {
|
||||
server.mockResponse({
|
||||
type: "success",
|
||||
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
|
||||
})
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
await collectEvents(
|
||||
streamCommandCode(makeModel({ maxTokens: 500_000 }), makeContext(), {
|
||||
apiKey: "mock-key",
|
||||
maxTokens: 500_000,
|
||||
headers: { "x-custom": "value" },
|
||||
}),
|
||||
)
|
||||
|
||||
assert.equal(objectAt(server.lastRequestBody(), ["params", "max_tokens"]), 64_000)
|
||||
assert.equal(server.lastRequestHeaders()["x-custom"], "value")
|
||||
})
|
||||
|
||||
it("caps default maxTokens by the selected model", async () => {
|
||||
server.mockResponse({
|
||||
type: "success",
|
||||
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
|
||||
})
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
await collectEvents(
|
||||
streamCommandCode(makeModel({ maxTokens: 8_192 }), makeContext(), {
|
||||
apiKey: "mock-key",
|
||||
}),
|
||||
)
|
||||
|
||||
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("times out a hung onResponse callback", async () => {
|
||||
server.mockResponse({
|
||||
type: "success",
|
||||
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
|
||||
})
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
const started = Date.now()
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), {
|
||||
apiKey: "mock-key",
|
||||
timeoutMs: 25,
|
||||
onResponse: async () => new Promise<void>(() => {}),
|
||||
}),
|
||||
1_000,
|
||||
)
|
||||
|
||||
assert.ok(Date.now() - started < 500)
|
||||
assert.deepEqual(eventTypes(events), ["start", "error"])
|
||||
const error = events.at(-1)
|
||||
assert.equal(error?.type, "error")
|
||||
if (error?.type !== "error") throw new Error("expected error")
|
||||
assert.match(error.error.errorMessage ?? "", /timed out after 25ms/)
|
||||
})
|
||||
|
||||
it("times out a hung onPayload callback", async () => {
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
const started = Date.now()
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), {
|
||||
apiKey: "mock-key",
|
||||
timeoutMs: 25,
|
||||
onPayload: async () => new Promise<unknown>(() => {}),
|
||||
}),
|
||||
1_000,
|
||||
)
|
||||
|
||||
assert.ok(Date.now() - started < 500)
|
||||
assert.deepEqual(eventTypes(events), ["start", "error"])
|
||||
const error = events.at(-1)
|
||||
assert.equal(error?.type, "error")
|
||||
if (error?.type !== "error") throw new Error("expected error")
|
||||
assert.match(error.error.errorMessage ?? "", /timed out after 25ms/)
|
||||
assert.equal(server.requestCount(), 0)
|
||||
})
|
||||
|
||||
it("runs onPayload and onResponse hooks", async () => {
|
||||
server.mockResponse({
|
||||
type: "success",
|
||||
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
|
||||
})
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
let responseStatus = 0
|
||||
|
||||
await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), {
|
||||
apiKey: "mock-key",
|
||||
onPayload: () => ({ replaced: true }),
|
||||
onResponse: (response) => {
|
||||
responseStatus = response.status
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
assert.equal(objectAt(server.lastRequestBody(), ["replaced"]), true)
|
||||
assert.equal(responseStatus, 200)
|
||||
})
|
||||
})
|
||||
|
||||
describe("streamCommandCode — upstream errors and malformed streams", () => {
|
||||
it("emits error for HTTP failures", async () => {
|
||||
server.mockResponse({ type: "error", status: 429, body: "rate limited" })
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
||||
)
|
||||
|
||||
assert.deepEqual(eventTypes(events), ["start", "error"])
|
||||
const error = events.at(-1)
|
||||
assert.equal(error?.type, "error")
|
||||
if (error?.type !== "error") throw new Error("expected error")
|
||||
assert.match(error.error.errorMessage ?? "", /429/)
|
||||
})
|
||||
|
||||
it("emits error for provider error events", async () => {
|
||||
server.mockResponse({
|
||||
type: "success",
|
||||
events: [
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: { message: "provider failed" },
|
||||
}),
|
||||
],
|
||||
})
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
||||
)
|
||||
|
||||
const error = events.at(-1)
|
||||
assert.equal(error?.type, "error")
|
||||
if (error?.type !== "error") throw new Error("expected error")
|
||||
assert.equal(error.error.errorMessage, "provider failed")
|
||||
})
|
||||
|
||||
it("handles SSE lines, malformed lines, split chunks, and final line without newline", async () => {
|
||||
const textEvent = `data: ${JSON.stringify({ type: "text-delta", text: "split" })}\n`
|
||||
const finishEvent = JSON.stringify({
|
||||
type: "finish",
|
||||
finishReason: "max_tokens",
|
||||
})
|
||||
server.mockResponse({
|
||||
type: "success",
|
||||
chunks: [
|
||||
"not json\n",
|
||||
textEvent.slice(0, 12),
|
||||
textEvent.slice(12),
|
||||
"event: ignored\n",
|
||||
"data: [DONE]\n",
|
||||
finishEvent,
|
||||
],
|
||||
})
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||
|
||||
const events = await collectEvents(
|
||||
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
||||
)
|
||||
|
||||
const done = events.at(-1)
|
||||
if (done?.type !== "done") throw new Error("expected done")
|
||||
assert.equal(done.reason, "length")
|
||||
assert.equal(
|
||||
done.message.content[0]?.type === "text" ? done.message.content[0].text : "",
|
||||
"split",
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user