feat(api): fall back for go plan accounts
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
|
||||
import type { MessageLike, StopReason, ToolLike } from "./types.ts"
|
||||
import { toJsonSchema } from "./json-schema.ts"
|
||||
|
||||
export { toJsonSchema } from "./json-schema.ts"
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
export function recordArray(value: unknown): readonly Record<string, unknown>[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter(isRecord)
|
||||
}
|
||||
|
||||
export function recordOrEmpty(value: unknown): Record<string, unknown> {
|
||||
if (isRecord(value)) return value
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value)
|
||||
if (isRecord(parsed)) return parsed
|
||||
} catch {
|
||||
// Some providers stream incomplete JSON argument fragments.
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export function numberValue(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
function defaultAuthPaths(home: string): string[] {
|
||||
return [
|
||||
join(home, ".commandcode", "auth.json"),
|
||||
join(home, ".omp", "agent", "auth.json"),
|
||||
join(home, ".pi", "agent", "auth.json"),
|
||||
]
|
||||
}
|
||||
|
||||
function apiKeyFromCredentialRecord(value: unknown): string | undefined {
|
||||
if (!isRecord(value)) return undefined
|
||||
|
||||
const type = stringValue(value.type)
|
||||
if (type === "api") return stringValue(value.key)
|
||||
if (type === "oauth") return stringValue(value.access)
|
||||
|
||||
return stringValue(value.key) ?? stringValue(value.access)
|
||||
}
|
||||
|
||||
function imageParts(value: unknown): readonly Record<string, unknown>[] {
|
||||
if (isRecord(value)) return value.type === "image" ? [value] : []
|
||||
return recordArray(value).filter((part) => part.type === "image")
|
||||
}
|
||||
|
||||
function imageContentError(role: string): Error {
|
||||
return new Error(`Selected Command Code model does not support image content in ${role}`)
|
||||
}
|
||||
|
||||
export function assertTextOnlyMessages(messages?: readonly MessageLike[]): void {
|
||||
for (const message of messages ?? []) {
|
||||
if (imageParts(message.content).length > 0) {
|
||||
const role = message.role === "toolResult" ? "tool results" : `${message.role} messages`
|
||||
throw imageContentError(role)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function imageToCommandCode(part: Record<string, unknown>): Record<string, string> {
|
||||
const data = stringValue(part.data)
|
||||
const mimeType = stringValue(part.mimeType)
|
||||
if (!data || !mimeType)
|
||||
throw new Error("Invalid image content: expected base64 data and mimeType")
|
||||
|
||||
return {
|
||||
type: "image",
|
||||
image: `data:${mimeType};base64,${data}`,
|
||||
mimeType,
|
||||
}
|
||||
}
|
||||
|
||||
function userContentToCommandCode(content: unknown, allowImages: boolean): unknown {
|
||||
if (typeof content === "string") return content
|
||||
|
||||
return recordArray(content).flatMap((part) => {
|
||||
if (part.type === "text") return [{ type: "text", text: stringValue(part.text) ?? "" }]
|
||||
if (part.type === "image") {
|
||||
if (!allowImages) throw imageContentError("user messages")
|
||||
return [imageToCommandCode(part)]
|
||||
}
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
export function getApiKey(
|
||||
options: {
|
||||
env?: NodeJS.ProcessEnv
|
||||
authPaths?: readonly string[]
|
||||
homeDir?: () => string
|
||||
} = {},
|
||||
): string | undefined {
|
||||
const env = options.env ?? process.env
|
||||
if (env.COMMANDCODE_API_KEY) return env.COMMANDCODE_API_KEY
|
||||
|
||||
const home = options.homeDir?.() ?? homedir()
|
||||
const authPaths = options.authPaths ?? defaultAuthPaths(home)
|
||||
|
||||
for (const authPath of authPaths) {
|
||||
try {
|
||||
if (!existsSync(authPath)) continue
|
||||
const parsed: unknown = JSON.parse(readFileSync(authPath, "utf-8"))
|
||||
if (!isRecord(parsed)) continue
|
||||
|
||||
// Legacy: direct apiKey or commandcode field.
|
||||
const apiKey = stringValue(parsed.apiKey)
|
||||
if (apiKey) return apiKey
|
||||
const commandcode = stringValue(parsed.commandcode)
|
||||
if (commandcode) return commandcode
|
||||
|
||||
// pi stores OAuth credentials as {"commandcode": {"type":"oauth","access":"..."}}.
|
||||
// The official Command Code CLI stores API credentials under "command-code".
|
||||
const providerKey =
|
||||
apiKeyFromCredentialRecord(parsed.commandcode) ??
|
||||
apiKeyFromCredentialRecord(parsed["command-code"])
|
||||
if (providerKey) return providerKey
|
||||
} catch {
|
||||
// Ignore malformed or unreadable auth files.
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function textContent(message: { content?: unknown }): string {
|
||||
return recordArray(message.content)
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => stringValue(part.text) ?? "")
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
export function getEnvironmentInfo(): string {
|
||||
return `${process.platform}-${process.arch}, Node.js ${process.version}`
|
||||
}
|
||||
|
||||
export function toolsToJson(tools?: readonly ToolLike[]): unknown[] {
|
||||
if (!tools) return []
|
||||
return tools.map((tool) => ({
|
||||
type: "function",
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
input_schema: tool.parameters ? toJsonSchema(tool.parameters) : {},
|
||||
}))
|
||||
}
|
||||
|
||||
function completeToolCallIds(messages?: readonly MessageLike[]): Set<string> {
|
||||
const callIds = new Set<string>()
|
||||
const resultIds = new Set<string>()
|
||||
|
||||
for (const message of messages ?? []) {
|
||||
if (message.role === "assistant") {
|
||||
for (const content of recordArray(message.content)) {
|
||||
if (content.type === "toolCall") {
|
||||
const id = stringValue(content.id)
|
||||
if (id) callIds.add(id)
|
||||
}
|
||||
}
|
||||
} else if (message.role === "toolResult") {
|
||||
if (message.toolCallId) resultIds.add(message.toolCallId)
|
||||
}
|
||||
}
|
||||
|
||||
return new Set([...callIds].filter((id) => resultIds.has(id)))
|
||||
}
|
||||
|
||||
export function messagesToCC(
|
||||
messages?: readonly MessageLike[],
|
||||
options: { allowImages?: boolean } = {},
|
||||
): unknown[] {
|
||||
const allowImages = options.allowImages ?? false
|
||||
if (!allowImages) assertTextOnlyMessages(messages)
|
||||
|
||||
const out: unknown[] = []
|
||||
const pairedToolCallIds = completeToolCallIds(messages)
|
||||
|
||||
for (const message of messages ?? []) {
|
||||
if (message.role === "user") {
|
||||
out.push({
|
||||
role: "user",
|
||||
content: userContentToCommandCode(message.content, allowImages),
|
||||
})
|
||||
} else if (message.role === "assistant") {
|
||||
const parts: unknown[] = []
|
||||
for (const content of recordArray(message.content)) {
|
||||
if (content.type === "text") {
|
||||
parts.push({ type: "text", text: stringValue(content.text) ?? "" })
|
||||
} else if (content.type === "toolCall") {
|
||||
const toolCallId = stringValue(content.id) ?? ""
|
||||
if (!pairedToolCallIds.has(toolCallId)) continue
|
||||
parts.push({
|
||||
type: "tool-call",
|
||||
toolCallId,
|
||||
toolName: stringValue(content.name) ?? "",
|
||||
input: recordOrEmpty(content.arguments),
|
||||
})
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) out.push({ role: "assistant", content: parts })
|
||||
} else if (message.role === "toolResult") {
|
||||
if (!message.toolCallId || !pairedToolCallIds.has(message.toolCallId)) continue
|
||||
out.push({
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: message.toolCallId,
|
||||
toolName: message.toolName,
|
||||
output: message.isError
|
||||
? { type: "error-text", value: textContent(message) }
|
||||
: { type: "text", value: textContent(message) },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const images = imageParts(message.content)
|
||||
if (images.length > 0) {
|
||||
if (!allowImages) throw imageContentError("tool results")
|
||||
out.push({
|
||||
role: "user",
|
||||
content: images.map(imageToCommandCode),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function parseStreamEventLine(line: string): unknown | undefined {
|
||||
let trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith(":") || trimmed.startsWith("event:")) return undefined
|
||||
if (trimmed.startsWith("data:")) trimmed = trimmed.slice(5).trim()
|
||||
if (!trimmed || trimmed === "[DONE]") return undefined
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed)
|
||||
return parsed
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function mapFinishReason(reason: unknown): StopReason {
|
||||
if (reason === "tool-calls") return "toolUse"
|
||||
if (
|
||||
reason === "length" ||
|
||||
reason === "max_tokens" ||
|
||||
reason === "max-tokens" ||
|
||||
reason === "max_output_tokens"
|
||||
) {
|
||||
return "length"
|
||||
}
|
||||
return "stop"
|
||||
}
|
||||
|
||||
function promptPartToText(value: unknown, depth = 0): string {
|
||||
if (depth > 10) return ""
|
||||
if (typeof value === "string") return value
|
||||
if (Array.isArray(value))
|
||||
return value
|
||||
.map((v) => promptPartToText(v, depth + 1))
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
if (!isRecord(value)) return ""
|
||||
const text = stringValue(value.text)
|
||||
if (text) return text
|
||||
const content = promptPartToText(value.content, depth + 1)
|
||||
if (content) return content
|
||||
return ""
|
||||
}
|
||||
|
||||
export function systemPromptToText(value: unknown): string {
|
||||
if (value === undefined || value === null) return ""
|
||||
if (typeof value === "string") return value
|
||||
if (Array.isArray(value))
|
||||
return value
|
||||
.map((v) => promptPartToText(v, 0))
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
return promptPartToText(value, 0)
|
||||
}
|
||||
+741
@@ -0,0 +1,741 @@
|
||||
/**
|
||||
* Testable Command Code provider core.
|
||||
*
|
||||
* The runtime imports live in index.ts; this module takes injected stream/cost
|
||||
* dependencies so tests can exercise the real serialization and stream parser.
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto"
|
||||
|
||||
import { commandCodeErrorMessage, redactCommandCodeErrorText } from "./overflow.ts"
|
||||
import { modelSupportsImageInput } from "./models.ts"
|
||||
import {
|
||||
getApiKey,
|
||||
getEnvironmentInfo,
|
||||
isRecord,
|
||||
assertTextOnlyMessages,
|
||||
mapFinishReason,
|
||||
messagesToCC,
|
||||
numberValue,
|
||||
parseStreamEventLine,
|
||||
recordOrEmpty,
|
||||
stringValue,
|
||||
toolsToJson,
|
||||
systemPromptToText,
|
||||
} from "./converters.ts"
|
||||
import type {
|
||||
AssistantMessageEventStreamLike,
|
||||
AssistantMessageLike,
|
||||
ContextLike,
|
||||
CoreDependencies,
|
||||
ErrorReason,
|
||||
ModelLike,
|
||||
StopReason,
|
||||
StreamOptions,
|
||||
TerminalReason,
|
||||
TextContent,
|
||||
ToolCallContent,
|
||||
Usage,
|
||||
} from "./types.ts"
|
||||
|
||||
export * from "./converters.ts"
|
||||
export * from "./overflow.ts"
|
||||
export * from "./types.ts"
|
||||
|
||||
export const DEFAULT_API_BASE = "https://api.commandcode.ai"
|
||||
export const COMMAND_CODE_CLI_VERSION = "1.15.1"
|
||||
|
||||
const DEFAULT_GENERATE_MAX_TOKENS = 64_000
|
||||
const DEFAULT_MAX_RETRIES = 0
|
||||
const DEFAULT_MAX_RETRY_DELAY_MS = 60_000
|
||||
const BASE_RETRY_DELAY_MS = 500
|
||||
|
||||
function isRetryableStatus(status: number): boolean {
|
||||
return status === 429 || (status >= 500 && status < 600)
|
||||
}
|
||||
|
||||
function parseRetryAfterSeconds(value: string | null): number | undefined {
|
||||
if (!value) return undefined
|
||||
const seconds = Number(value)
|
||||
if (Number.isFinite(seconds) && seconds >= 0) return seconds
|
||||
const date = Date.parse(value)
|
||||
if (!Number.isNaN(date)) return Math.max(0, (date - Date.now()) / 1000)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function effectiveMaxRetryDelayMs(value: number | undefined): number {
|
||||
if (value === undefined) return DEFAULT_MAX_RETRY_DELAY_MS
|
||||
if (value === 0) return Number.POSITIVE_INFINITY
|
||||
return value
|
||||
}
|
||||
|
||||
function retryDelayMs(
|
||||
attempt: number,
|
||||
retryAfterHeader: string | null,
|
||||
maxDelayMs: number,
|
||||
): number {
|
||||
const retryAfterMs = parseRetryAfterSeconds(retryAfterHeader)
|
||||
if (retryAfterMs !== undefined) {
|
||||
if (retryAfterMs * 1000 > maxDelayMs) return -1
|
||||
return retryAfterMs * 1000
|
||||
}
|
||||
const exponential = BASE_RETRY_DELAY_MS * 2 ** attempt
|
||||
const jitter = exponential * 0.2 * Math.random()
|
||||
return Math.min(exponential + jitter, maxDelayMs)
|
||||
}
|
||||
|
||||
function defaultUsage(): Usage {
|
||||
return {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
function commandCodeUsage(event: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
return isRecord(event.totalUsage) ? event.totalUsage : undefined
|
||||
}
|
||||
|
||||
function commandCodeInputTokenDetails(
|
||||
usage: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
return isRecord(usage.inputTokenDetails) ? usage.inputTokenDetails : undefined
|
||||
}
|
||||
|
||||
function headersToRecord(headers: Headers): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
headers.forEach((value, key) => {
|
||||
out[key] = value
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
function abortError(message = "The operation was aborted"): DOMException {
|
||||
return new DOMException(message, "AbortError")
|
||||
}
|
||||
|
||||
function timeoutError(timeoutMs: number | undefined): Error {
|
||||
return new Error(
|
||||
timeoutMs === undefined
|
||||
? "Command Code API request timed out"
|
||||
: `Command Code API request timed out after ${timeoutMs}ms`,
|
||||
)
|
||||
}
|
||||
|
||||
function successStopReason(reason: TerminalReason): StopReason {
|
||||
if (reason === "length" || reason === "toolUse") return reason
|
||||
return "stop"
|
||||
}
|
||||
|
||||
function generateMaxTokens(model: ModelLike, options?: StreamOptions): number {
|
||||
return Math.min(
|
||||
options?.maxTokens ?? model.maxTokens,
|
||||
model.maxTokens,
|
||||
DEFAULT_GENERATE_MAX_TOKENS,
|
||||
)
|
||||
}
|
||||
|
||||
function mappedReasoningEffort(model: ModelLike, options?: StreamOptions): string | undefined {
|
||||
const level = options?.reasoning
|
||||
if (!level || level === "off" || !model.reasoning) return undefined
|
||||
|
||||
const effortMap = model.thinking?.effortMap ?? model.thinkingLevelMap
|
||||
const mapped = effortMap?.[level]
|
||||
return typeof mapped === "string" && mapped !== "off" ? mapped : undefined
|
||||
}
|
||||
|
||||
export function projectSlugFromPath(pathName: string): string {
|
||||
const slug = pathName
|
||||
.toLowerCase()
|
||||
.replace(/^[a-z]:/i, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
return slug || "project"
|
||||
}
|
||||
|
||||
export function createStreamCommandCode(deps: CoreDependencies) {
|
||||
const apiBase = deps.apiBase ?? DEFAULT_API_BASE
|
||||
const fetchImpl = deps.fetchImpl ?? fetch
|
||||
const cwd = deps.cwd ?? (() => process.cwd())
|
||||
const now = deps.now ?? (() => Date.now())
|
||||
const uuid = deps.uuid ?? (() => randomUUID())
|
||||
const delay =
|
||||
deps.delay ??
|
||||
((ms: number, signal: AbortSignal) => {
|
||||
if (signal.aborted) return Promise.reject(abortError())
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const id = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort)
|
||||
resolve()
|
||||
}, ms)
|
||||
const onAbort = () => {
|
||||
clearTimeout(id)
|
||||
reject(abortError())
|
||||
}
|
||||
signal.addEventListener("abort", onAbort, { once: true })
|
||||
})
|
||||
})
|
||||
|
||||
function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
if (signal.aborted) return Promise.reject(abortError())
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = () => reject(abortError())
|
||||
signal.addEventListener("abort", onAbort, { once: true })
|
||||
promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener("abort", onAbort)
|
||||
resolve(value)
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener("abort", onAbort)
|
||||
reject(error)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function raceAbortWithTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
controller: AbortController,
|
||||
timeoutMs: number | undefined,
|
||||
): Promise<T> {
|
||||
if (timeoutMs === undefined) return raceAbort(promise, controller.signal)
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
controller.abort()
|
||||
reject(timeoutError(timeoutMs))
|
||||
}, timeoutMs)
|
||||
raceAbort(promise, controller.signal).then(
|
||||
(value) => {
|
||||
clearTimeout(timer)
|
||||
resolve(value)
|
||||
},
|
||||
(error: unknown) => {
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return function streamCommandCode(
|
||||
model: ModelLike,
|
||||
context: ContextLike,
|
||||
options?: StreamOptions,
|
||||
): AssistantMessageEventStreamLike {
|
||||
const stream = deps.createStream()
|
||||
|
||||
async function run() {
|
||||
// OMP may pass the legacy env-var name "COMMANDCODE_API_KEY" (old pi)
|
||||
// or "$COMMANDCODE_API_KEY" (new pi) as the apiKey value instead of
|
||||
// resolving it. Filter out these specific strings.
|
||||
const LEGACY_API_KEY_REF = "$COMMANDCODE_API_KEY"
|
||||
const OLD_API_KEY_REF = "COMMANDCODE_API_KEY"
|
||||
const hostKey =
|
||||
options?.apiKey &&
|
||||
options.apiKey !== LEGACY_API_KEY_REF &&
|
||||
options.apiKey !== OLD_API_KEY_REF
|
||||
? options.apiKey
|
||||
: undefined
|
||||
|
||||
const apiKey =
|
||||
hostKey ??
|
||||
getApiKey({
|
||||
env: deps.env,
|
||||
authPaths: deps.authPaths,
|
||||
homeDir: deps.homeDir,
|
||||
})
|
||||
|
||||
if (!apiKey) {
|
||||
const msg: AssistantMessageLike = {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: defaultUsage(),
|
||||
stopReason: "error",
|
||||
errorMessage:
|
||||
"No Command Code API key. Run /login and select Command Code, set the COMMANDCODE_API_KEY env var, or configure ~/.commandcode/auth.json, ~/.pi/agent/auth.json or ~/.omp/agent/auth.json",
|
||||
timestamp: now(),
|
||||
}
|
||||
stream.push({ type: "error", reason: "error", error: msg })
|
||||
stream.end()
|
||||
return
|
||||
}
|
||||
|
||||
const output: AssistantMessageLike = {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: defaultUsage(),
|
||||
stopReason: "stop",
|
||||
timestamp: now(),
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
|
||||
let textBlock: TextContent | undefined
|
||||
let currentTextIdx = -1
|
||||
let thinkingIdx = -1
|
||||
let finished = false
|
||||
|
||||
const abortUpstream = () => {
|
||||
if (!controller.signal.aborted) controller.abort()
|
||||
try {
|
||||
reader?.cancel().catch(() => undefined)
|
||||
} catch {
|
||||
// Reader cancellation is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.signal?.aborted) {
|
||||
abortUpstream()
|
||||
} else {
|
||||
options?.signal?.addEventListener("abort", abortUpstream, {
|
||||
once: true,
|
||||
})
|
||||
}
|
||||
|
||||
const endTextBlock = () => {
|
||||
if (!textBlock) return
|
||||
stream.push({
|
||||
type: "text_end",
|
||||
contentIndex: currentTextIdx,
|
||||
content: textBlock.text,
|
||||
partial: output,
|
||||
})
|
||||
textBlock = undefined
|
||||
currentTextIdx = -1
|
||||
}
|
||||
|
||||
const endThinking = () => {
|
||||
if (thinkingIdx < 0) return
|
||||
const tc = output.content[thinkingIdx]
|
||||
if (tc && tc.type === "thinking") {
|
||||
stream.push({
|
||||
type: "thinking_end",
|
||||
contentIndex: thinkingIdx,
|
||||
content: (tc as { thinking: string }).thinking,
|
||||
partial: output,
|
||||
})
|
||||
}
|
||||
thinkingIdx = -1
|
||||
}
|
||||
|
||||
const handleEvent = (event: unknown) => {
|
||||
if (!isRecord(event)) return
|
||||
|
||||
switch (event.type) {
|
||||
case "text-delta": {
|
||||
endThinking()
|
||||
if (!textBlock) {
|
||||
textBlock = { type: "text", text: "" }
|
||||
output.content.push(textBlock)
|
||||
currentTextIdx = output.content.length - 1
|
||||
stream.push({
|
||||
type: "text_start",
|
||||
contentIndex: currentTextIdx,
|
||||
partial: output,
|
||||
})
|
||||
}
|
||||
const delta = stringValue(event.text) ?? ""
|
||||
textBlock.text += delta
|
||||
stream.push({
|
||||
type: "text_delta",
|
||||
contentIndex: currentTextIdx,
|
||||
delta,
|
||||
partial: output,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case "reasoning-start": {
|
||||
endTextBlock()
|
||||
break
|
||||
}
|
||||
|
||||
case "reasoning-delta": {
|
||||
endTextBlock()
|
||||
const delta = stringValue(event.text) ?? ""
|
||||
if (thinkingIdx < 0) {
|
||||
output.content.push({ type: "thinking", thinking: delta })
|
||||
thinkingIdx = output.content.length - 1
|
||||
stream.push({
|
||||
type: "thinking_start",
|
||||
contentIndex: thinkingIdx,
|
||||
partial: output,
|
||||
})
|
||||
} else {
|
||||
const tc = output.content[thinkingIdx]
|
||||
if (tc && tc.type === "thinking") {
|
||||
;(tc as { thinking: string }).thinking += delta
|
||||
}
|
||||
}
|
||||
stream.push({
|
||||
type: "thinking_delta",
|
||||
contentIndex: thinkingIdx,
|
||||
delta,
|
||||
partial: output,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case "reasoning-end": {
|
||||
endThinking()
|
||||
break
|
||||
}
|
||||
|
||||
case "tool-result": {
|
||||
break
|
||||
}
|
||||
|
||||
case "tool-call": {
|
||||
endTextBlock()
|
||||
endThinking()
|
||||
const toolCall: ToolCallContent = {
|
||||
type: "toolCall",
|
||||
id: stringValue(event.toolCallId) ?? "",
|
||||
name: stringValue(event.toolName) ?? "",
|
||||
arguments: recordOrEmpty(event.input ?? event.args ?? event.arguments),
|
||||
}
|
||||
output.content.push(toolCall)
|
||||
const idx = output.content.length - 1
|
||||
stream.push({
|
||||
type: "toolcall_start",
|
||||
contentIndex: idx,
|
||||
partial: output,
|
||||
})
|
||||
stream.push({
|
||||
type: "toolcall_end",
|
||||
contentIndex: idx,
|
||||
toolCall,
|
||||
partial: output,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case "finish": {
|
||||
const usage = commandCodeUsage(event)
|
||||
if (usage) {
|
||||
const details = commandCodeInputTokenDetails(usage)
|
||||
const totalInput = numberValue(usage.inputTokens) ?? 0
|
||||
const input = numberValue(details?.noCacheTokens)
|
||||
const cacheRead = numberValue(details?.cacheReadTokens) ?? 0
|
||||
const cacheWrite = numberValue(details?.cacheWriteTokens) ?? 0
|
||||
output.usage.input = input ?? Math.max(0, totalInput - cacheRead - cacheWrite)
|
||||
output.usage.output = numberValue(usage.outputTokens) ?? 0
|
||||
output.usage.cacheRead = cacheRead
|
||||
output.usage.cacheWrite = cacheWrite
|
||||
output.usage.totalTokens =
|
||||
output.usage.input +
|
||||
output.usage.output +
|
||||
output.usage.cacheRead +
|
||||
output.usage.cacheWrite
|
||||
deps.calculateCost(model, output.usage)
|
||||
}
|
||||
output.stopReason = mapFinishReason(event.finishReason)
|
||||
finished = true
|
||||
break
|
||||
}
|
||||
|
||||
case "error": {
|
||||
const message =
|
||||
commandCodeErrorMessage(event.error) ??
|
||||
commandCodeErrorMessage(event.message) ??
|
||||
"Stream error"
|
||||
output.stopReason = "error"
|
||||
output.errorMessage = message
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
stream.push({ type: "start", partial: output })
|
||||
if (controller.signal.aborted) throw abortError("Aborted")
|
||||
|
||||
const workingDir = cwd()
|
||||
const threadId = uuid()
|
||||
const reasoningEffort = mappedReasoningEffort(model, options)
|
||||
const timeoutMs = options?.timeoutMs
|
||||
|
||||
const allowImages = modelSupportsImageInput(model.id)
|
||||
if (!allowImages) assertTextOnlyMessages(context.messages)
|
||||
|
||||
let body: unknown = {
|
||||
config: {
|
||||
workingDir,
|
||||
date: new Date(now()).toISOString().split("T")[0],
|
||||
environment: getEnvironmentInfo(),
|
||||
structure: [],
|
||||
isGitRepo: false,
|
||||
currentBranch: "",
|
||||
mainBranch: "",
|
||||
gitStatus: "",
|
||||
recentCommits: [],
|
||||
},
|
||||
memory: null,
|
||||
taste: null,
|
||||
skills: null,
|
||||
params: {
|
||||
model: model.id,
|
||||
messages: messagesToCC(context.messages, { allowImages }),
|
||||
tools: toolsToJson(context.tools),
|
||||
system: systemPromptToText(context.systemPrompt),
|
||||
max_tokens: generateMaxTokens(model, options),
|
||||
temperature: 0.3,
|
||||
stream: true,
|
||||
...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
|
||||
},
|
||||
threadId,
|
||||
}
|
||||
|
||||
const payloadController = new AbortController()
|
||||
const onPayloadAbort = () => payloadController.abort()
|
||||
controller.signal.addEventListener("abort", onPayloadAbort, { once: true })
|
||||
let nextBody: unknown
|
||||
try {
|
||||
nextBody = await raceAbortWithTimeout(
|
||||
Promise.resolve(options?.onPayload?.(body, model)),
|
||||
payloadController,
|
||||
timeoutMs,
|
||||
)
|
||||
} finally {
|
||||
controller.signal.removeEventListener("abort", onPayloadAbort)
|
||||
}
|
||||
if (nextBody !== undefined) body = nextBody
|
||||
|
||||
const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES
|
||||
const maxRetryDelayMs = effectiveMaxRetryDelayMs(options?.maxRetryDelayMs)
|
||||
const requestHeaders = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"x-command-code-version": COMMAND_CODE_CLI_VERSION,
|
||||
"x-cli-environment": "production",
|
||||
"x-project-slug": projectSlugFromPath(workingDir),
|
||||
"x-taste-learning": "true",
|
||||
"x-co-flag": "false",
|
||||
...options?.headers,
|
||||
}
|
||||
const bodyStr = JSON.stringify(body)
|
||||
|
||||
let response!: Response
|
||||
retryLoop: for (let attempt = 0; ; attempt++) {
|
||||
const attemptController = new AbortController()
|
||||
let attemptTimedOut = false
|
||||
let attemptTimeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const clearAttemptTimeout = () => {
|
||||
if (attemptTimeoutId !== undefined) {
|
||||
clearTimeout(attemptTimeoutId)
|
||||
attemptTimeoutId = undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (timeoutMs !== undefined) {
|
||||
attemptTimeoutId = setTimeout(() => {
|
||||
attemptTimedOut = true
|
||||
attemptController.abort()
|
||||
}, timeoutMs)
|
||||
}
|
||||
const onOuterAbort = () => attemptController.abort()
|
||||
controller.signal.addEventListener("abort", onOuterAbort, { once: true })
|
||||
const raceAttempt = <T>(promise: Promise<T>): Promise<T> =>
|
||||
raceAbort(promise, attemptController.signal).catch((error: unknown) => {
|
||||
if (attemptTimedOut) throw timeoutError(timeoutMs)
|
||||
throw error
|
||||
})
|
||||
|
||||
try {
|
||||
try {
|
||||
response = await fetchImpl(`${apiBase}/alpha/generate`, {
|
||||
method: "POST",
|
||||
headers: requestHeaders,
|
||||
body: bodyStr,
|
||||
signal: attemptController.signal,
|
||||
})
|
||||
} catch (fetchError: unknown) {
|
||||
if (controller.signal.aborted) throw abortError("Aborted")
|
||||
if (attemptTimedOut) {
|
||||
if (attempt < maxRetries) continue retryLoop
|
||||
throw timeoutError(timeoutMs)
|
||||
}
|
||||
throw fetchError
|
||||
}
|
||||
|
||||
// --- HTTP-level retry ---
|
||||
if (!response.ok && isRetryableStatus(response.status)) {
|
||||
const retryAfter = response.headers.get("retry-after")
|
||||
const waitMs = retryDelayMs(attempt, retryAfter, maxRetryDelayMs)
|
||||
if (waitMs < 0) {
|
||||
const requestedSeconds = parseRetryAfterSeconds(retryAfter) ?? 0
|
||||
const capLabel =
|
||||
maxRetryDelayMs === Number.POSITIVE_INFINITY ? "disabled" : `${maxRetryDelayMs}ms`
|
||||
throw new Error(`Retry-After delay ${requestedSeconds}s exceeds max ${capLabel}`)
|
||||
}
|
||||
if (attempt < maxRetries) {
|
||||
await response.text().catch(() => "")
|
||||
if (waitMs > 0) await delay(waitMs, controller.signal)
|
||||
continue retryLoop
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await raceAttempt(
|
||||
Promise.resolve(
|
||||
options?.onResponse?.(
|
||||
{
|
||||
status: response.status,
|
||||
headers: headersToRecord(response.headers),
|
||||
},
|
||||
model,
|
||||
),
|
||||
),
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
if (attemptTimedOut && attempt < maxRetries) continue retryLoop
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errBody = await raceAttempt(response.text().catch(() => ""))
|
||||
let errorDetail: string | undefined
|
||||
try {
|
||||
const parsedBody: unknown = JSON.parse(errBody)
|
||||
errorDetail = commandCodeErrorMessage(parsedBody)
|
||||
} catch {
|
||||
// Preserve useful plain-text provider errors only after secret
|
||||
// redaction; upstream/proxy bodies may echo credentials.
|
||||
}
|
||||
const safeBody = redactCommandCodeErrorText(errBody).slice(0, 500)
|
||||
const detail = redactCommandCodeErrorText(
|
||||
errorDetail ?? (safeBody || "Provider returned an error"),
|
||||
)
|
||||
throw new Error(`Command Code API error ${response.status}: ${detail}`)
|
||||
}
|
||||
|
||||
// --- Read response stream ---
|
||||
reader = response.body?.getReader()
|
||||
if (!reader) throw new Error("No response body")
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
|
||||
try {
|
||||
readLoop: for (;;) {
|
||||
if (controller.signal.aborted) throw abortError("Aborted")
|
||||
const { done, value } = await raceAbort(reader.read(), attemptController.signal)
|
||||
if (done) {
|
||||
if (buffer.trim()) handleEvent(parseStreamEventLine(buffer))
|
||||
break
|
||||
}
|
||||
if (controller.signal.aborted) throw abortError("Aborted")
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split("\n")
|
||||
buffer = lines.pop() ?? ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (controller.signal.aborted) throw abortError("Aborted")
|
||||
handleEvent(parseStreamEventLine(line))
|
||||
if (finished) break readLoop
|
||||
}
|
||||
}
|
||||
} catch (streamError: unknown) {
|
||||
// Stream-level error (e.g. API returned 200 OK but sent an error event)
|
||||
// or per-attempt timeout during stream reading.
|
||||
await reader.cancel().catch(() => {})
|
||||
try {
|
||||
reader.releaseLock()
|
||||
} catch {}
|
||||
reader = undefined
|
||||
|
||||
if (controller.signal.aborted) throw streamError
|
||||
|
||||
// Never retry after visible content was emitted (including timeout mid-stream).
|
||||
const canRetry = output.content.length === 0 && attempt < maxRetries
|
||||
if (canRetry) {
|
||||
output.content.length = 0
|
||||
textBlock = undefined
|
||||
currentTextIdx = -1
|
||||
thinkingIdx = -1
|
||||
output.stopReason = "stop"
|
||||
output.errorMessage = undefined
|
||||
finished = false
|
||||
const waitMs = attemptTimedOut ? 0 : retryDelayMs(attempt, null, maxRetryDelayMs)
|
||||
if (waitMs > 0) await delay(waitMs, controller.signal)
|
||||
continue retryLoop
|
||||
}
|
||||
if (attemptTimedOut) throw timeoutError(timeoutMs)
|
||||
throw streamError
|
||||
}
|
||||
|
||||
// Stream completed successfully.
|
||||
endTextBlock()
|
||||
endThinking()
|
||||
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason: successStopReason(output.stopReason),
|
||||
message: output,
|
||||
})
|
||||
stream.end()
|
||||
break retryLoop
|
||||
} finally {
|
||||
controller.signal.removeEventListener("abort", onOuterAbort)
|
||||
clearAttemptTimeout()
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const reason: ErrorReason = controller.signal.aborted ? "aborted" : "error"
|
||||
output.stopReason = reason
|
||||
output.errorMessage =
|
||||
reason === "aborted"
|
||||
? "Request aborted"
|
||||
: redactCommandCodeErrorText(error instanceof Error ? error.message : String(error))
|
||||
stream.push({ type: "error", reason, error: output })
|
||||
stream.end()
|
||||
} finally {
|
||||
options?.signal?.removeEventListener("abort", abortUpstream)
|
||||
try {
|
||||
await reader?.cancel()
|
||||
} catch {
|
||||
// Reader may already be closed/cancelled.
|
||||
}
|
||||
try {
|
||||
reader?.releaseLock()
|
||||
} catch {
|
||||
// Reader may already be released/cancelled by the abort path.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error: unknown) => {
|
||||
const msg: AssistantMessageLike = {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: defaultUsage(),
|
||||
stopReason: "error",
|
||||
errorMessage: redactCommandCodeErrorText(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
),
|
||||
timestamp: now(),
|
||||
}
|
||||
stream.push({ type: "error", reason: "error", error: msg })
|
||||
stream.end()
|
||||
})
|
||||
|
||||
return stream
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Local cost calculation for Command Code usage.
|
||||
*
|
||||
* Mirrors pi-ai's `calculateCost` arithmetic exactly. The provider ships its
|
||||
* own copy because Oh My Pi's legacy pi-ai shim does not export
|
||||
* `calculateCost`, which broke extension installation there (issue #24).
|
||||
* `tests/test-cost.ts` locks this implementation to the pi-ai original.
|
||||
*/
|
||||
|
||||
import type { ModelLike, Usage } from "./types.ts"
|
||||
|
||||
export function calculateCommandCodeCost(model: ModelLike, usage: Usage): void {
|
||||
const inputTokens = usage.input + usage.cacheRead + usage.cacheWrite
|
||||
let rates = model.cost
|
||||
let matchedThreshold = -1
|
||||
for (const tier of model.cost.tiers ?? []) {
|
||||
if (inputTokens > tier.inputTokensAbove && tier.inputTokensAbove > matchedThreshold) {
|
||||
rates = tier
|
||||
matchedThreshold = tier.inputTokensAbove
|
||||
}
|
||||
}
|
||||
|
||||
const longWrite = usage.cacheWrite1h ?? 0
|
||||
const shortWrite = usage.cacheWrite - longWrite
|
||||
usage.cost.input = (rates.input / 1_000_000) * usage.input
|
||||
usage.cost.output = (rates.output / 1_000_000) * usage.output
|
||||
usage.cost.cacheRead = (rates.cacheRead / 1_000_000) * usage.cacheRead
|
||||
usage.cost.cacheWrite = (rates.cacheWrite * shortWrite + rates.input * 2 * longWrite) / 1_000_000
|
||||
usage.cost.total =
|
||||
usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown): boolean | undefined {
|
||||
return typeof value === "boolean" ? value : undefined
|
||||
}
|
||||
|
||||
type JsonSchemaValue = boolean | Record<string, unknown>
|
||||
|
||||
const JSON_SCHEMA_TYPES = new Set([
|
||||
"array",
|
||||
"boolean",
|
||||
"integer",
|
||||
"null",
|
||||
"number",
|
||||
"object",
|
||||
"string",
|
||||
])
|
||||
|
||||
const LEGACY_KINDS = new Set([
|
||||
"any",
|
||||
"array",
|
||||
"boolean",
|
||||
"enum",
|
||||
"integer",
|
||||
"intersect",
|
||||
"intersection",
|
||||
"literal",
|
||||
"never",
|
||||
"null",
|
||||
"nullable",
|
||||
"number",
|
||||
"object",
|
||||
"optional",
|
||||
"string",
|
||||
"undefined",
|
||||
"union",
|
||||
"unknown",
|
||||
])
|
||||
|
||||
const LEGACY_FIELDS = new Set([
|
||||
"element",
|
||||
"kind",
|
||||
"inner",
|
||||
"optional",
|
||||
"value",
|
||||
"values",
|
||||
"variants",
|
||||
"wrapped",
|
||||
])
|
||||
|
||||
const SCHEMA_MAP_FIELDS = new Set([
|
||||
"$defs",
|
||||
"definitions",
|
||||
"dependentSchemas",
|
||||
"patternProperties",
|
||||
"properties",
|
||||
])
|
||||
|
||||
const SCHEMA_ARRAY_FIELDS = new Set(["allOf", "anyOf", "oneOf", "prefixItems"])
|
||||
|
||||
const SCHEMA_VALUE_FIELDS = new Set([
|
||||
"additionalItems",
|
||||
"additionalProperties",
|
||||
"contains",
|
||||
"contentSchema",
|
||||
"else",
|
||||
"if",
|
||||
"items",
|
||||
"not",
|
||||
"propertyNames",
|
||||
"then",
|
||||
"unevaluatedItems",
|
||||
"unevaluatedProperties",
|
||||
])
|
||||
|
||||
const SCHEMA_KEYWORDS = new Set([
|
||||
"$anchor",
|
||||
"$comment",
|
||||
"$defs",
|
||||
"$dynamicAnchor",
|
||||
"$dynamicRef",
|
||||
"$id",
|
||||
"$ref",
|
||||
"$schema",
|
||||
"$vocabulary",
|
||||
"additionalItems",
|
||||
"additionalProperties",
|
||||
"allOf",
|
||||
"anyOf",
|
||||
"const",
|
||||
"contains",
|
||||
"contentEncoding",
|
||||
"contentMediaType",
|
||||
"contentSchema",
|
||||
"default",
|
||||
"definitions",
|
||||
"dependentRequired",
|
||||
"dependentSchemas",
|
||||
"description",
|
||||
"else",
|
||||
"enum",
|
||||
"examples",
|
||||
"exclusiveMaximum",
|
||||
"exclusiveMinimum",
|
||||
"format",
|
||||
"if",
|
||||
"items",
|
||||
"maxContains",
|
||||
"maxItems",
|
||||
"maxLength",
|
||||
"maxProperties",
|
||||
"maximum",
|
||||
"minContains",
|
||||
"minItems",
|
||||
"minLength",
|
||||
"minProperties",
|
||||
"minimum",
|
||||
"multipleOf",
|
||||
"not",
|
||||
"oneOf",
|
||||
"pattern",
|
||||
"patternProperties",
|
||||
"prefixItems",
|
||||
"properties",
|
||||
"propertyNames",
|
||||
"readOnly",
|
||||
"required",
|
||||
"title",
|
||||
"type",
|
||||
"unevaluatedItems",
|
||||
"unevaluatedProperties",
|
||||
"uniqueItems",
|
||||
"writeOnly",
|
||||
])
|
||||
|
||||
function stringArray(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined
|
||||
const values = value.filter((item): item is string => typeof item === "string")
|
||||
return values.length === value.length ? values : undefined
|
||||
}
|
||||
|
||||
function validSchemaType(value: unknown): boolean {
|
||||
if (typeof value === "string") return JSON_SCHEMA_TYPES.has(value)
|
||||
if (!Array.isArray(value) || value.length === 0) return false
|
||||
return value.every((item) => typeof item === "string" && JSON_SCHEMA_TYPES.has(item))
|
||||
}
|
||||
|
||||
function legacyKind(schema: Record<string, unknown>): string | undefined {
|
||||
const explicitKind = stringValue(schema.kind)?.toLowerCase()
|
||||
if (explicitKind && LEGACY_KINDS.has(explicitKind)) return explicitKind
|
||||
|
||||
const type = stringValue(schema.type)
|
||||
const normalized = type?.toLowerCase()
|
||||
if (!normalized || !LEGACY_KINDS.has(normalized)) return undefined
|
||||
if (!validSchemaType(type) || Object.keys(schema).some((key) => LEGACY_FIELDS.has(key))) {
|
||||
return normalized
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function looksLikeJsonSchema(schema: Record<string, unknown>): boolean {
|
||||
if (Object.keys(schema).length === 0) return true
|
||||
if (schema.type !== undefined && !validSchemaType(schema.type)) return false
|
||||
return Object.keys(schema).some((key) => SCHEMA_KEYWORDS.has(key))
|
||||
}
|
||||
|
||||
function isOptionalSchema(schema: unknown): boolean {
|
||||
if (!isRecord(schema)) return false
|
||||
if (booleanValue(schema.optional) === true) return true
|
||||
|
||||
const kind = legacyKind(schema)
|
||||
if (kind === "optional") return true
|
||||
if (kind !== "union") return false
|
||||
|
||||
const variants = Array.isArray(schema.variants)
|
||||
? schema.variants
|
||||
: Array.isArray(schema.anyOf)
|
||||
? schema.anyOf
|
||||
: []
|
||||
return variants.some((variant) => legacyKind(isRecord(variant) ? variant : {}) === "undefined")
|
||||
}
|
||||
|
||||
function schemaValue(value: unknown, seen: WeakSet<object>): JsonSchemaValue {
|
||||
if (typeof value === "boolean") return value
|
||||
if (!isRecord(value)) return {}
|
||||
return convertSchema(value, seen)
|
||||
}
|
||||
|
||||
function setSchemaProperty(target: Record<string, unknown>, key: string, value: unknown): void {
|
||||
Object.defineProperty(target, key, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value,
|
||||
writable: true,
|
||||
})
|
||||
}
|
||||
|
||||
function schemaMap(value: unknown, seen: WeakSet<object>): Record<string, unknown> {
|
||||
if (!isRecord(value)) return {}
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
setSchemaProperty(out, key, schemaValue(item, seen))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function schemaArray(value: unknown, seen: WeakSet<object>): unknown[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.map((item) => schemaValue(item, seen))
|
||||
}
|
||||
|
||||
function isSchemaValue(value: unknown): value is JsonSchemaValue {
|
||||
return typeof value === "boolean" || isRecord(value)
|
||||
}
|
||||
|
||||
function copySchemaObject(
|
||||
source: Record<string, unknown>,
|
||||
seen: WeakSet<object>,
|
||||
legacy: boolean,
|
||||
forcedType?: string,
|
||||
): JsonSchemaValue {
|
||||
const out: Record<string, unknown> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (legacy && LEGACY_FIELDS.has(key)) continue
|
||||
if (key === "nullable" || (forcedType !== undefined && key === "type")) continue
|
||||
|
||||
if (key === "required") {
|
||||
const required = stringArray(value)
|
||||
if (required) out.required = required
|
||||
} else if (SCHEMA_MAP_FIELDS.has(key)) {
|
||||
out[key] = schemaMap(value, seen)
|
||||
} else if (SCHEMA_ARRAY_FIELDS.has(key)) {
|
||||
out[key] = schemaArray(value, seen)
|
||||
} else if (SCHEMA_VALUE_FIELDS.has(key)) {
|
||||
out[key] =
|
||||
Array.isArray(value) && key === "items"
|
||||
? schemaArray(value, seen)
|
||||
: schemaValue(value, seen)
|
||||
} else {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
if (forcedType !== undefined) out.type = forcedType
|
||||
if (booleanValue(source.nullable) === true) return makeNullable(out)
|
||||
return out
|
||||
}
|
||||
|
||||
function makeNullable(schema: Record<string, unknown>): Record<string, unknown> {
|
||||
const type = schema.type
|
||||
if (typeof type === "string") {
|
||||
if (type === "null") return schema
|
||||
return { ...schema, type: [type, "null"] }
|
||||
}
|
||||
if (Array.isArray(type) && !type.includes("null")) {
|
||||
return { ...schema, type: [...type, "null"] }
|
||||
}
|
||||
if (Array.isArray(schema.anyOf)) {
|
||||
return { ...schema, anyOf: [...schema.anyOf, { type: "null" }] }
|
||||
}
|
||||
return { anyOf: [schema, { type: "null" }] }
|
||||
}
|
||||
|
||||
function legacyVariants(schema: Record<string, unknown>): unknown[] {
|
||||
if (Array.isArray(schema.variants)) return schema.variants
|
||||
if (Array.isArray(schema.anyOf)) return schema.anyOf
|
||||
return []
|
||||
}
|
||||
|
||||
function convertLegacySchema(
|
||||
source: Record<string, unknown>,
|
||||
kind: string,
|
||||
seen: WeakSet<object>,
|
||||
): JsonSchemaValue {
|
||||
if (kind === "optional") return schemaValue(source.wrapped ?? source.inner, seen)
|
||||
if (kind === "nullable") {
|
||||
const wrapped = schemaValue(source.wrapped ?? source.inner, seen)
|
||||
return typeof wrapped === "boolean" ? wrapped : makeNullable(wrapped)
|
||||
}
|
||||
if (kind === "undefined" || kind === "never" || kind === "any" || kind === "unknown") return {}
|
||||
|
||||
if (kind === "union" || kind === "intersect" || kind === "intersection") {
|
||||
const variants = legacyVariants(source)
|
||||
.map((variant) => schemaValue(variant, seen))
|
||||
.filter(
|
||||
(variant) =>
|
||||
isSchemaValue(variant) &&
|
||||
(typeof variant === "boolean" || Object.keys(variant).length > 0),
|
||||
)
|
||||
if (variants.length === 0) return copySchemaObject(source, seen, true)
|
||||
if (variants.length === 1) return variants[0] ?? {}
|
||||
|
||||
const out = copySchemaObject(source, seen, true)
|
||||
if (typeof out !== "boolean") out[kind === "union" ? "anyOf" : "allOf"] = variants
|
||||
return out
|
||||
}
|
||||
|
||||
if (kind === "object") {
|
||||
const converted = copySchemaObject(source, seen, true, "object")
|
||||
if (typeof converted === "boolean") return converted
|
||||
const out = converted
|
||||
const sourceProperties = isRecord(source.properties) ? source.properties : undefined
|
||||
if (!sourceProperties) return out
|
||||
|
||||
const properties: Record<string, unknown> = {}
|
||||
const optional = stringArray(source.optional) ?? []
|
||||
for (const [key, value] of Object.entries(sourceProperties)) {
|
||||
setSchemaProperty(properties, key, schemaValue(value, seen))
|
||||
}
|
||||
out.properties = properties
|
||||
|
||||
const explicitRequired = stringArray(source.required)
|
||||
const required =
|
||||
explicitRequired ??
|
||||
Object.entries(sourceProperties)
|
||||
.filter(([key, value]) => !optional.includes(key) && !isOptionalSchema(value))
|
||||
.map(([key]) => key)
|
||||
if (required.length > 0) out.required = required
|
||||
else delete out.required
|
||||
return out
|
||||
}
|
||||
|
||||
if (kind === "array") {
|
||||
const converted = copySchemaObject(source, seen, true, "array")
|
||||
if (typeof converted === "boolean") return converted
|
||||
const out = converted
|
||||
if (!("items" in source) && "element" in source) out.items = schemaValue(source.element, seen)
|
||||
return out
|
||||
}
|
||||
|
||||
if (kind === "enum") {
|
||||
const converted = copySchemaObject(source, seen, true)
|
||||
if (typeof converted === "boolean") return converted
|
||||
const out = converted
|
||||
if (!("enum" in out) && Array.isArray(source.values)) out.enum = source.values
|
||||
return out
|
||||
}
|
||||
|
||||
if (kind === "literal") {
|
||||
const converted = copySchemaObject(source, seen, true)
|
||||
if (typeof converted === "boolean") return converted
|
||||
const out = converted
|
||||
if (!("const" in out) && "value" in source) out.const = source.value
|
||||
return out
|
||||
}
|
||||
|
||||
const scalarType =
|
||||
kind === "string" ||
|
||||
kind === "number" ||
|
||||
kind === "boolean" ||
|
||||
kind === "integer" ||
|
||||
kind === "null"
|
||||
? kind
|
||||
: undefined
|
||||
return scalarType ? copySchemaObject(source, seen, true, scalarType) : {}
|
||||
}
|
||||
|
||||
function convertSchema(source: Record<string, unknown>, seen: WeakSet<object>): JsonSchemaValue {
|
||||
if (seen.has(source)) return {}
|
||||
seen.add(source)
|
||||
try {
|
||||
const kind = legacyKind(source)
|
||||
if (kind) return convertLegacySchema(source, kind, seen)
|
||||
if (!looksLikeJsonSchema(source)) return {}
|
||||
return copySchemaObject(source, seen, false)
|
||||
} finally {
|
||||
seen.delete(source)
|
||||
}
|
||||
}
|
||||
|
||||
export function toJsonSchema(schema: unknown): unknown {
|
||||
if (typeof schema === "boolean") return schema
|
||||
if (!isRecord(schema)) return {}
|
||||
return convertSchema(schema, new WeakSet<object>())
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
const COMMAND_CODE_PROVIDER = "commandcode"
|
||||
const CONTEXT_OVERFLOW_PREFIX = "context_length_exceeded:"
|
||||
|
||||
const COMMAND_CODE_OVERFLOW_PATTERNS = [
|
||||
/\b(?:context[_\s-]*(?:length|window)|model[_\s-]*context[_\s-]*window)[_\s-]*(?:exceeded|overflow(?:ed)?|too[_\s-]*(?:large|long))\b/i,
|
||||
/\b(?:context|prompt|input)[_\s-]*(?:length|window|size|tokens?|limit|maximum)\b[\s\S]{0,120}\b(?:exceed(?:ed|s)?|overflow(?:ed|s)?|too\s+(?:large|long)|(?:maximum|limit)\s+(?:reached|exceeded|hit))\b/i,
|
||||
/\b(?:exceed(?:ed|s)?|overflow(?:ed|s)?|too\s+(?:large|long))\b[\s\S]{0,120}\b(?:context|prompt|input)[_\s-]*(?:length|window|size|tokens?|limit|maximum)\b/i,
|
||||
/\b(?:prompt|input|context)\b[\s\S]{0,32}\btoo\s+(?:large|long)\b/i,
|
||||
/\b(?:prompt|input)[_\s-]*too[_\s-]*(?:large|long)\b/i,
|
||||
/\b(?:prompt|input)[_\s-]*tokens?[_\s-]*(?:limit|maximum|max)[_\s-]*(?:exceeded|reached)\b/i,
|
||||
/\b(?:prompt|input)[_\s-]*(?:tokens?|length|size)\b[\s\S]{0,120}\b(?:limit|maximum)\b[\s\S]{0,40}\b(?:exceed(?:ed|s)?|reached|hit)\b/i,
|
||||
/\b(?:maximum|limit)[_\s-]+(?:allowed[_\s-]+)?(?:context|prompt|input)[_\s-]*(?:length|window|size|tokens?)\b/i,
|
||||
]
|
||||
|
||||
const NON_OVERFLOW_PATTERNS = [
|
||||
/\brate[_\s-]*limit\b/i,
|
||||
/\btoo\s+many\s+requests\b/i,
|
||||
/\b(?:capacity|quota|throttl(?:e|ed|ing)?|concurren(?:cy|t)|overloaded)\b/i,
|
||||
/\b(?:service|temporarily)\s+unavailable\b/i,
|
||||
/\bstatus(?:[_\s-]*code)?\s*[:=]\s*429\b/i,
|
||||
]
|
||||
|
||||
const CONTEXT_OVERFLOW_PREFIX_PATTERN = /context_length_exceeded/i
|
||||
|
||||
const HTTP_RATE_LIMIT_STATUS_PATTERNS = [
|
||||
/\b(?:api\s+error|http|status(?:[_\s-]*code)?|status[_\s-]*code)\s*[:(]?\s*429\b/i,
|
||||
/["']?(?:status|status[_\s-]*code)["']?\s*:\s*429\b/i,
|
||||
]
|
||||
|
||||
const BEARER_PATTERN = /\bBearer\s+[A-Za-z0-9._~+/=-]+/gi
|
||||
const CREDENTIAL_PATTERN =
|
||||
/\b(?:api[-_ ]?key|apikey|access[-_ ]?token|refresh[-_ ]?token|token|secret|password|authorization)\s*[=:]\s*[^\s,;)]+/gi
|
||||
const USER_TOKEN_PATTERN = /\b(?:user|cc)_[A-Za-z0-9_-]{8,}\b/gi
|
||||
const QUERY_SECRET_PATTERN =
|
||||
/([?&](?:api[-_ ]?key|apikey|access_token|refresh_token|token|secret|password)=)[^&#\s]+/gi
|
||||
const STANDALONE_SECRET_PATTERN =
|
||||
/\b(?:sk|rk|ghp|github_pat|xox[baprs])[-_A-Za-z0-9]{16,}\b|\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g
|
||||
|
||||
export function redactCommandCodeErrorText(value: string): string {
|
||||
return value
|
||||
.replace(BEARER_PATTERN, "Bearer [redacted]")
|
||||
.replace(CREDENTIAL_PATTERN, (match) => {
|
||||
const separatorIndex = match.search(/[=:]/)
|
||||
return separatorIndex < 0 ? "[redacted]" : `${match.slice(0, separatorIndex + 1)}[redacted]`
|
||||
})
|
||||
.replace(USER_TOKEN_PATTERN, "[redacted]")
|
||||
.replace(QUERY_SECRET_PATTERN, "$1[redacted]")
|
||||
.replace(STANDALONE_SECRET_PATTERN, "[redacted]")
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
export interface CommandCodeMessageLike {
|
||||
role: string
|
||||
provider: string
|
||||
stopReason: string
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
export function commandCodeErrorMessage(value: unknown): string | undefined {
|
||||
if (typeof value === "string") return value
|
||||
if (!isRecord(value)) return undefined
|
||||
|
||||
const record = value
|
||||
const parts: string[] = []
|
||||
for (const key of [
|
||||
"message",
|
||||
"errorMessage",
|
||||
"error",
|
||||
"detail",
|
||||
"details",
|
||||
"code",
|
||||
"type",
|
||||
"reason",
|
||||
]) {
|
||||
const part = commandCodeErrorMessage(record[key])
|
||||
if (part && !parts.includes(part)) parts.push(part)
|
||||
}
|
||||
|
||||
for (const key of ["status", "statusCode", "httpStatus"]) {
|
||||
const status = record[key]
|
||||
if (typeof status === "string" || typeof status === "number") {
|
||||
const statusPart = `status: ${status}`
|
||||
if (!parts.includes(statusPart)) parts.push(statusPart)
|
||||
}
|
||||
}
|
||||
|
||||
return parts.length > 0 ? redactCommandCodeErrorText(parts.join(": ")) : undefined
|
||||
}
|
||||
|
||||
export function normalizeCommandCodeErrorMessage(
|
||||
errorMessage: string | undefined,
|
||||
): string | undefined {
|
||||
if (!errorMessage) return undefined
|
||||
if (CONTEXT_OVERFLOW_PREFIX_PATTERN.test(errorMessage)) return undefined
|
||||
if (NON_OVERFLOW_PATTERNS.some((pattern) => pattern.test(errorMessage))) return undefined
|
||||
if (HTTP_RATE_LIMIT_STATUS_PATTERNS.some((pattern) => pattern.test(errorMessage)))
|
||||
return undefined
|
||||
if (!COMMAND_CODE_OVERFLOW_PATTERNS.some((pattern) => pattern.test(errorMessage)))
|
||||
return undefined
|
||||
|
||||
return `${CONTEXT_OVERFLOW_PREFIX} ${errorMessage}`
|
||||
}
|
||||
|
||||
export function normalizeCommandCodeMessage<T extends CommandCodeMessageLike>(
|
||||
message: T,
|
||||
modelProvider?: string,
|
||||
): { message: T & { errorMessage: string } } | undefined {
|
||||
if (message.role !== "assistant" || message.stopReason !== "error") return undefined
|
||||
if (message.provider !== COMMAND_CODE_PROVIDER && modelProvider !== COMMAND_CODE_PROVIDER) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const errorMessage = normalizeCommandCodeErrorMessage(message.errorMessage)
|
||||
if (!errorMessage) return undefined
|
||||
|
||||
return { message: { ...message, errorMessage } }
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import type {
|
||||
AssistantMessageEvent,
|
||||
AssistantMessageEventStreamLike,
|
||||
ContextLike,
|
||||
ModelLike,
|
||||
StreamOptions,
|
||||
} from "./types.ts"
|
||||
|
||||
export type CommandCodeTransport = "unknown" | "provider" | "generate"
|
||||
|
||||
interface TransportDependencies {
|
||||
createStream: () => AssistantMessageEventStreamLike
|
||||
streamProvider: (
|
||||
model: ModelLike,
|
||||
context: ContextLike,
|
||||
options?: StreamOptions,
|
||||
) => AssistantMessageEventStreamLike
|
||||
streamGenerate: (
|
||||
model: ModelLike,
|
||||
context: ContextLike,
|
||||
options?: StreamOptions,
|
||||
) => AssistantMessageEventStreamLike
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
async function isUpgradeRequired(response: Response): Promise<boolean> {
|
||||
if (response.status !== 403) return false
|
||||
|
||||
try {
|
||||
const body: unknown = await response.clone().json()
|
||||
if (!isRecord(body)) return false
|
||||
const error = isRecord(body.error) ? body.error : body
|
||||
return error.code === "upgrade_required"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function createCommandCodeTransportRouter(deps: TransportDependencies) {
|
||||
let transport: CommandCodeTransport = "unknown"
|
||||
let apiKey: string | undefined
|
||||
|
||||
function pipe(
|
||||
source: AssistantMessageEventStreamLike,
|
||||
target: AssistantMessageEventStreamLike,
|
||||
): Promise<void> {
|
||||
return (async () => {
|
||||
for await (const event of source) target.push(event)
|
||||
})()
|
||||
}
|
||||
|
||||
return {
|
||||
getTransport(): CommandCodeTransport {
|
||||
return transport
|
||||
},
|
||||
|
||||
reset(): void {
|
||||
transport = "unknown"
|
||||
apiKey = undefined
|
||||
},
|
||||
|
||||
stream(
|
||||
model: ModelLike,
|
||||
context: ContextLike,
|
||||
options?: StreamOptions,
|
||||
): AssistantMessageEventStreamLike {
|
||||
if (options?.apiKey !== apiKey) {
|
||||
apiKey = options?.apiKey
|
||||
transport = "unknown"
|
||||
}
|
||||
if (transport === "generate") return deps.streamGenerate(model, context, options)
|
||||
|
||||
const output = deps.createStream()
|
||||
let upgradeRequired = false
|
||||
const fetchImpl = options?.fetch ?? fetch
|
||||
const providerOptions: StreamOptions = {
|
||||
...options,
|
||||
fetch: async (input, init) => {
|
||||
const response = await fetchImpl(input, init)
|
||||
if (await isUpgradeRequired(response)) upgradeRequired = true
|
||||
return response
|
||||
},
|
||||
onResponse: async (response, responseModel) => {
|
||||
if (upgradeRequired) return
|
||||
await options?.onResponse?.(response, responseModel)
|
||||
},
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
const providerStream = deps.streamProvider(model, context, providerOptions)
|
||||
|
||||
for await (const event of providerStream) {
|
||||
if (!upgradeRequired) {
|
||||
transport = "provider"
|
||||
output.push(event)
|
||||
}
|
||||
}
|
||||
|
||||
if (upgradeRequired) {
|
||||
transport = "generate"
|
||||
await pipe(deps.streamGenerate(model, context, options), output)
|
||||
}
|
||||
output.end()
|
||||
}
|
||||
|
||||
run().catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
output.push({
|
||||
type: "error",
|
||||
reason: "error",
|
||||
error: {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "error",
|
||||
errorMessage: message,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
})
|
||||
output.end()
|
||||
})
|
||||
|
||||
return output
|
||||
},
|
||||
}
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
export type StopReason = "stop" | "length" | "toolUse"
|
||||
export type ErrorReason = "error" | "aborted"
|
||||
export type TerminalReason = StopReason | ErrorReason
|
||||
|
||||
export interface UsageCost {
|
||||
input: number
|
||||
output: number
|
||||
cacheRead: number
|
||||
cacheWrite: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface Usage {
|
||||
input: number
|
||||
output: number
|
||||
cacheRead: number
|
||||
cacheWrite: number
|
||||
cacheWrite1h?: number
|
||||
totalTokens: number
|
||||
cost: UsageCost
|
||||
}
|
||||
|
||||
export interface TextContent {
|
||||
type: "text"
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface ThinkingContent {
|
||||
type: "thinking"
|
||||
thinking: string
|
||||
}
|
||||
|
||||
export interface ToolCallContent {
|
||||
type: "toolCall"
|
||||
id: string
|
||||
name: string
|
||||
arguments: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type AssistantContent = TextContent | ThinkingContent | ToolCallContent
|
||||
|
||||
export interface AssistantMessageLike {
|
||||
role: "assistant"
|
||||
content: AssistantContent[]
|
||||
api: unknown
|
||||
provider: string
|
||||
model: string
|
||||
usage: Usage
|
||||
stopReason: TerminalReason
|
||||
errorMessage?: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface ModelCostRates {
|
||||
input: number
|
||||
output: number
|
||||
cacheRead: number
|
||||
cacheWrite: number
|
||||
}
|
||||
|
||||
export interface ModelCostTier extends ModelCostRates {
|
||||
inputTokensAbove: number
|
||||
}
|
||||
|
||||
export interface ModelCost extends ModelCostRates {
|
||||
tiers?: readonly ModelCostTier[]
|
||||
}
|
||||
|
||||
export interface ModelLike {
|
||||
id: string
|
||||
api: unknown
|
||||
provider: string
|
||||
maxTokens: number
|
||||
cost: ModelCost
|
||||
reasoning?: boolean
|
||||
thinkingLevelMap?: Partial<Record<string, string | null>>
|
||||
thinking?: {
|
||||
mode?: "effort"
|
||||
effortMap?: Partial<Record<string, string>>
|
||||
efforts?: readonly string[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface MessageLike {
|
||||
role: string
|
||||
content?: unknown
|
||||
toolCallId?: string
|
||||
toolName?: string
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
export interface ToolLike {
|
||||
name: string
|
||||
description?: string
|
||||
parameters?: unknown
|
||||
}
|
||||
|
||||
export interface ContextLike {
|
||||
systemPrompt?: string
|
||||
messages?: readonly MessageLike[]
|
||||
tools?: readonly ToolLike[]
|
||||
}
|
||||
|
||||
export interface ProviderResponseInfo {
|
||||
status: number
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
export interface StreamOptions {
|
||||
apiKey?: string
|
||||
signal?: AbortSignal
|
||||
headers?: Record<string, string>
|
||||
fetch?: typeof fetch
|
||||
maxTokens?: number
|
||||
/** Resolved pi thinking level; forwarded only through the model's map. */
|
||||
reasoning?: string
|
||||
onPayload?: (payload: unknown, model: ModelLike) => unknown | Promise<unknown>
|
||||
onResponse?: (response: ProviderResponseInfo, model: ModelLike) => void | Promise<void>
|
||||
/**
|
||||
* HTTP request timeout in milliseconds.
|
||||
* Applied per-attempt; on timeout the request is retried if retries remain.
|
||||
*/
|
||||
timeoutMs?: number
|
||||
/**
|
||||
* Maximum retry attempts for transient HTTP errors (429, 5xx).
|
||||
* Default: 0 (pi agent-level retry handles visible retries when unset).
|
||||
*/
|
||||
maxRetries?: number
|
||||
/**
|
||||
* Maximum delay in milliseconds to wait for a retry when the server requests
|
||||
* a long wait via Retry-After. If the server's requested delay exceeds this
|
||||
* value, the request fails immediately. Default: 60000 (60 seconds).
|
||||
* Set to 0 to disable the cap.
|
||||
*/
|
||||
maxRetryDelayMs?: number
|
||||
}
|
||||
|
||||
export type AssistantMessageEvent =
|
||||
| { type: "start"; partial: AssistantMessageLike }
|
||||
| { type: "text_start"; contentIndex: number; partial: AssistantMessageLike }
|
||||
| {
|
||||
type: "text_delta"
|
||||
contentIndex: number
|
||||
delta: string
|
||||
partial: AssistantMessageLike
|
||||
}
|
||||
| {
|
||||
type: "text_end"
|
||||
contentIndex: number
|
||||
content: string
|
||||
partial: AssistantMessageLike
|
||||
}
|
||||
| {
|
||||
type: "thinking_start"
|
||||
contentIndex: number
|
||||
partial: AssistantMessageLike
|
||||
}
|
||||
| {
|
||||
type: "thinking_delta"
|
||||
contentIndex: number
|
||||
delta: string
|
||||
partial: AssistantMessageLike
|
||||
}
|
||||
| {
|
||||
type: "thinking_end"
|
||||
contentIndex: number
|
||||
content: string
|
||||
partial: AssistantMessageLike
|
||||
}
|
||||
| {
|
||||
type: "toolcall_start"
|
||||
contentIndex: number
|
||||
partial: AssistantMessageLike
|
||||
}
|
||||
| {
|
||||
type: "toolcall_end"
|
||||
contentIndex: number
|
||||
toolCall: ToolCallContent
|
||||
partial: AssistantMessageLike
|
||||
}
|
||||
| { type: "done"; reason: StopReason; message: AssistantMessageLike }
|
||||
| { type: "error"; reason: ErrorReason; error: AssistantMessageLike }
|
||||
|
||||
export interface AssistantMessageEventStreamLike extends AsyncIterable<AssistantMessageEvent> {
|
||||
push(event: AssistantMessageEvent): void
|
||||
end(): void
|
||||
}
|
||||
|
||||
export interface CoreDependencies {
|
||||
createStream: () => AssistantMessageEventStreamLike
|
||||
calculateCost: (model: ModelLike, usage: Usage) => void
|
||||
apiBase?: string
|
||||
fetchImpl?: typeof fetch
|
||||
authPaths?: readonly string[]
|
||||
env?: NodeJS.ProcessEnv
|
||||
cwd?: () => string
|
||||
now?: () => number
|
||||
uuid?: () => string
|
||||
homeDir?: () => string
|
||||
/** Injectable delay for retry backoff. Defaults to setTimeout. */
|
||||
delay?: (ms: number, signal: AbortSignal) => Promise<void>
|
||||
}
|
||||
Reference in New Issue
Block a user