fix(core): preserve request fidelity and provider errors
This commit is contained in:
@@ -2,14 +2,8 @@
|
|||||||
* Command Code provider for pi.
|
* Command Code provider for pi.
|
||||||
*
|
*
|
||||||
* Connects pi to Command Code's API (https://api.commandcode.ai/alpha/generate).
|
* Connects pi to Command Code's API (https://api.commandcode.ai/alpha/generate).
|
||||||
*
|
* The provider uses pi's legacy extension registration surface because the
|
||||||
* Authentication (pick one):
|
* current pi host exposes `registerProvider(name, config)`, including OMP.
|
||||||
* 1. Run `/login`, then select Command Code — opens browser to commandcode.ai, auto-stores API key
|
|
||||||
* 2. Set COMMANDCODE_API_KEY environment variable
|
|
||||||
* 3. Place API key in `~/.commandcode/auth.json` or `~/.pi/agent/auth.json`
|
|
||||||
* as {"apiKey": "user_..."} or {"commandcode": "user_..."}
|
|
||||||
*
|
|
||||||
* Models are fetched from Command Code's Provider API at startup.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { AssistantMessageEventStream } from "@earendil-works/pi-ai"
|
import { AssistantMessageEventStream } from "@earendil-works/pi-ai"
|
||||||
@@ -21,8 +15,12 @@ import {
|
|||||||
} from "@earendil-works/pi-coding-agent"
|
} from "@earendil-works/pi-coding-agent"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
|
|
||||||
import { getApiKey as getStoredApiKey } from "./src/converters.ts"
|
import {
|
||||||
import { COMMAND_CODE_CLI_VERSION, createStreamCommandCode, DEFAULT_API_BASE } from "./src/core.ts"
|
COMMAND_CODE_CLI_VERSION,
|
||||||
|
COMMAND_CODE_INPUT_TYPES,
|
||||||
|
createStreamCommandCode,
|
||||||
|
DEFAULT_API_BASE,
|
||||||
|
} from "./src/core.ts"
|
||||||
import { calculateCommandCodeCost } from "./src/cost.ts"
|
import { calculateCommandCodeCost } from "./src/cost.ts"
|
||||||
import {
|
import {
|
||||||
DEFAULT_MODELS_URL,
|
DEFAULT_MODELS_URL,
|
||||||
@@ -34,6 +32,7 @@ import {
|
|||||||
import { getApiKey as getOAuthApiKey, login, refreshToken } from "./src/oauth.ts"
|
import { getApiKey as getOAuthApiKey, login, refreshToken } from "./src/oauth.ts"
|
||||||
import { MODEL_COSTS, ZERO_MODEL_COST } from "./src/pricing.ts"
|
import { MODEL_COSTS, ZERO_MODEL_COST } from "./src/pricing.ts"
|
||||||
import { createCommandCodeRuntime } from "./src/runtime.ts"
|
import { createCommandCodeRuntime } from "./src/runtime.ts"
|
||||||
|
import { normalizeCommandCodeMessage } from "./src/overflow.ts"
|
||||||
|
|
||||||
function createProviderConfig(
|
function createProviderConfig(
|
||||||
models: readonly CommandCodeModel[],
|
models: readonly CommandCodeModel[],
|
||||||
@@ -43,12 +42,16 @@ function createProviderConfig(
|
|||||||
return {
|
return {
|
||||||
name: "Command Code",
|
name: "Command Code",
|
||||||
baseUrl: apiBase,
|
baseUrl: apiBase,
|
||||||
|
// Keep environment authentication dynamic. OAuth credentials are resolved
|
||||||
|
// by pi's oauth registration, while the custom stream retains its own
|
||||||
|
// request-time legacy-file fallback for older compatible hosts.
|
||||||
apiKey: "$COMMANDCODE_API_KEY",
|
apiKey: "$COMMANDCODE_API_KEY",
|
||||||
authHeader: true,
|
authHeader: true,
|
||||||
api: "commandcode-custom",
|
api: "commandcode-custom",
|
||||||
streamSimple: streamCommandCode,
|
streamSimple: streamCommandCode,
|
||||||
headers: {
|
headers: {
|
||||||
"x-commandcode-version": COMMAND_CODE_CLI_VERSION,
|
"x-command-code-version": COMMAND_CODE_CLI_VERSION,
|
||||||
|
"x-cli-environment": "production",
|
||||||
},
|
},
|
||||||
oauth: {
|
oauth: {
|
||||||
name: "Command Code",
|
name: "Command Code",
|
||||||
@@ -56,19 +59,29 @@ function createProviderConfig(
|
|||||||
refreshToken,
|
refreshToken,
|
||||||
getApiKey: getOAuthApiKey,
|
getApiKey: getOAuthApiKey,
|
||||||
},
|
},
|
||||||
models: models.map((model) => ({
|
models: models.map(createProviderModel),
|
||||||
id: model.id,
|
|
||||||
name: model.name,
|
|
||||||
reasoning: model.reasoning,
|
|
||||||
...(thinkingMetadataForModel(model.id) ?? {}),
|
|
||||||
input: ["text"] as const,
|
|
||||||
cost: MODEL_COSTS[model.id] ?? ZERO_MODEL_COST,
|
|
||||||
contextWindow: model.contextWindow,
|
|
||||||
maxTokens: model.maxTokens,
|
|
||||||
})),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createProviderModel(model: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
reasoning: boolean
|
||||||
|
contextWindow: number
|
||||||
|
maxTokens: number
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: model.id,
|
||||||
|
name: model.name,
|
||||||
|
reasoning: model.reasoning,
|
||||||
|
...(thinkingMetadataForModel(model.id) ?? {}),
|
||||||
|
input: COMMAND_CODE_INPUT_TYPES,
|
||||||
|
cost: MODEL_COSTS[model.id] ?? ZERO_MODEL_COST,
|
||||||
|
contextWindow: model.contextWindow,
|
||||||
|
maxTokens: model.maxTokens,
|
||||||
|
} as const
|
||||||
|
}
|
||||||
|
|
||||||
export default async function (pi: ExtensionAPI) {
|
export default async function (pi: ExtensionAPI) {
|
||||||
const apiBase = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE
|
const apiBase = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE
|
||||||
const modelsUrl = process.env.COMMANDCODE_MODELS_URL ?? DEFAULT_MODELS_URL
|
const modelsUrl = process.env.COMMANDCODE_MODELS_URL ?? DEFAULT_MODELS_URL
|
||||||
@@ -81,6 +94,12 @@ export default async function (pi: ExtensionAPI) {
|
|||||||
apiBase,
|
apiBase,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
pi.on("message_end", async (event, ctx) => {
|
||||||
|
if (event.message.role !== "assistant") return
|
||||||
|
const normalized = normalizeCommandCodeMessage(event.message, ctx.model?.provider)
|
||||||
|
return normalized ? { message: normalized.message } : undefined
|
||||||
|
})
|
||||||
|
|
||||||
const runtime = createCommandCodeRuntime<ProviderConfig, ExtensionCommandContext>(pi, {
|
const runtime = createCommandCodeRuntime<ProviderConfig, ExtensionCommandContext>(pi, {
|
||||||
endpoint: modelsUrl,
|
endpoint: modelsUrl,
|
||||||
cachePath: modelsCachePath,
|
cachePath: modelsCachePath,
|
||||||
|
|||||||
+2
-1
@@ -29,13 +29,14 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "npm run typecheck && tsx tests/test-package-manifest.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-pricing.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-stream.ts && tsx tests/test-retry.ts && node tests/test-pi-isolated.mjs && node tests/test-pi-authenticated.mjs && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs",
|
"test": "npm run typecheck && tsx tests/test-package-manifest.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-pricing.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-overflow.ts && tsx tests/test-stream.ts && tsx tests/test-retry.ts && node tests/test-pi-isolated.mjs && node tests/test-pi-authenticated.mjs && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"format:check": "prettier --check '**/*.{ts,mjs,json,md}'",
|
"format:check": "prettier --check '**/*.{ts,mjs,json,md}'",
|
||||||
"format": "prettier --write '**/*.{ts,mjs,json,md}'",
|
"format": "prettier --write '**/*.{ts,mjs,json,md}'",
|
||||||
"pi:isolated": "node scripts/pi-isolated.mjs",
|
"pi:isolated": "node scripts/pi-isolated.mjs",
|
||||||
"pi:authenticated": "node scripts/pi-authenticated.mjs",
|
"pi:authenticated": "node scripts/pi-authenticated.mjs",
|
||||||
"test:unit": "tsx tests/test-pure-functions.ts",
|
"test:unit": "tsx tests/test-pure-functions.ts",
|
||||||
|
"test:overflow": "tsx tests/test-overflow.ts",
|
||||||
"test:models": "tsx tests/test-models.ts",
|
"test:models": "tsx tests/test-models.ts",
|
||||||
"test:pricing": "tsx tests/test-pricing.ts",
|
"test:pricing": "tsx tests/test-pricing.ts",
|
||||||
"test:oauth": "tsx tests/test-oauth.ts",
|
"test:oauth": "tsx tests/test-oauth.ts",
|
||||||
|
|||||||
+27
-78
@@ -3,6 +3,9 @@ import { homedir } from "node:os"
|
|||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
|
|
||||||
import type { MessageLike, StopReason, ToolLike } from "./types.ts"
|
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> {
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
@@ -12,10 +15,6 @@ export function stringValue(value: unknown): string | undefined {
|
|||||||
return typeof value === "string" ? value : undefined
|
return typeof value === "string" ? value : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function booleanValue(value: unknown): boolean | undefined {
|
|
||||||
return typeof value === "boolean" ? value : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function recordArray(value: unknown): readonly Record<string, unknown>[] {
|
export function recordArray(value: unknown): readonly Record<string, unknown>[] {
|
||||||
if (!Array.isArray(value)) return []
|
if (!Array.isArray(value)) return []
|
||||||
return value.filter(isRecord)
|
return value.filter(isRecord)
|
||||||
@@ -56,6 +55,26 @@ function apiKeyFromCredentialRecord(value: unknown): string | undefined {
|
|||||||
return stringValue(value.key) ?? stringValue(value.access)
|
return stringValue(value.key) ?? stringValue(value.access)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasImageContent(value: unknown): boolean {
|
||||||
|
if (isRecord(value)) return value.type === "image"
|
||||||
|
return recordArray(value).some((part) => part.type === "image")
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageContentError(role: string): Error {
|
||||||
|
return new Error(
|
||||||
|
`Command Code does not support image content in ${role}; refusing to send it to avoid lossy handling`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertTextOnlyMessages(messages?: readonly MessageLike[]): void {
|
||||||
|
for (const message of messages ?? []) {
|
||||||
|
if (hasImageContent(message.content)) {
|
||||||
|
const role = message.role === "toolResult" ? "tool results" : `${message.role} messages`
|
||||||
|
throw imageContentError(role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getApiKey(
|
export function getApiKey(
|
||||||
options: {
|
options: {
|
||||||
env?: NodeJS.ProcessEnv
|
env?: NodeJS.ProcessEnv
|
||||||
@@ -96,6 +115,8 @@ export function getApiKey(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function textContent(message: { content?: unknown }): string {
|
export function textContent(message: { content?: unknown }): string {
|
||||||
|
if (hasImageContent(message.content)) throw imageContentError("tool results")
|
||||||
|
|
||||||
return recordArray(message.content)
|
return recordArray(message.content)
|
||||||
.filter((part) => part.type === "text")
|
.filter((part) => part.type === "text")
|
||||||
.map((part) => stringValue(part.text) ?? "")
|
.map((part) => stringValue(part.text) ?? "")
|
||||||
@@ -106,80 +127,6 @@ export function getEnvironmentInfo(): string {
|
|||||||
return `${process.platform}-${process.arch}, Node.js ${process.version}`
|
return `${process.platform}-${process.arch}, Node.js ${process.version}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toJsonSchema(schema: unknown): unknown {
|
|
||||||
if (!isRecord(schema)) return {}
|
|
||||||
|
|
||||||
const kind = stringValue(schema.kind) ?? stringValue(schema.type)
|
|
||||||
const enumValues = Array.isArray(schema.enum) ? schema.enum : undefined
|
|
||||||
if (enumValues) {
|
|
||||||
return { type: typeof enumValues[0], enum: enumValues }
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (kind) {
|
|
||||||
case "string":
|
|
||||||
case "String":
|
|
||||||
return { type: "string" }
|
|
||||||
case "number":
|
|
||||||
case "Number":
|
|
||||||
return { type: "number" }
|
|
||||||
case "boolean":
|
|
||||||
case "Boolean":
|
|
||||||
return { type: "boolean" }
|
|
||||||
case "object":
|
|
||||||
case "Object": {
|
|
||||||
const properties: Record<string, unknown> = {}
|
|
||||||
const inferredRequired: string[] = []
|
|
||||||
const sourceProperties = isRecord(schema.properties) ? schema.properties : undefined
|
|
||||||
const optional = Array.isArray(schema.optional)
|
|
||||||
? schema.optional.filter((item): item is string => typeof item === "string")
|
|
||||||
: []
|
|
||||||
|
|
||||||
if (sourceProperties) {
|
|
||||||
for (const [key, value] of Object.entries(sourceProperties)) {
|
|
||||||
properties[key] = toJsonSchema(value)
|
|
||||||
const valueRecord = isRecord(value) ? value : undefined
|
|
||||||
if (booleanValue(valueRecord?.optional) !== true && !optional.includes(key)) {
|
|
||||||
inferredRequired.push(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const explicitRequired = Array.isArray(schema.required)
|
|
||||||
? schema.required.filter((item): item is string => typeof item === "string")
|
|
||||||
: undefined
|
|
||||||
const required = explicitRequired ?? inferredRequired
|
|
||||||
const out: Record<string, unknown> = { type: "object" }
|
|
||||||
if (Object.keys(properties).length > 0) out.properties = properties
|
|
||||||
if (required.length > 0) out.required = required
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
case "array":
|
|
||||||
case "Array":
|
|
||||||
return {
|
|
||||||
type: "array",
|
|
||||||
items: toJsonSchema(schema.items ?? schema.element),
|
|
||||||
}
|
|
||||||
case "union":
|
|
||||||
case "Union": {
|
|
||||||
const variants = Array.isArray(schema.variants)
|
|
||||||
? schema.variants
|
|
||||||
: Array.isArray(schema.anyOf)
|
|
||||||
? schema.anyOf
|
|
||||||
: []
|
|
||||||
for (const variant of variants) {
|
|
||||||
const converted = toJsonSchema(variant)
|
|
||||||
if (isRecord(converted) && Object.keys(converted).length > 0) return converted
|
|
||||||
}
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
case "optional":
|
|
||||||
case "Optional":
|
|
||||||
return toJsonSchema(schema.wrapped ?? schema.inner)
|
|
||||||
default:
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toolsToJson(tools?: readonly ToolLike[]): unknown[] {
|
export function toolsToJson(tools?: readonly ToolLike[]): unknown[] {
|
||||||
if (!tools) return []
|
if (!tools) return []
|
||||||
return tools.map((tool) => ({
|
return tools.map((tool) => ({
|
||||||
@@ -211,6 +158,8 @@ function completeToolCallIds(messages?: readonly MessageLike[]): Set<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function messagesToCC(messages?: readonly MessageLike[]): unknown[] {
|
export function messagesToCC(messages?: readonly MessageLike[]): unknown[] {
|
||||||
|
assertTextOnlyMessages(messages)
|
||||||
|
|
||||||
const out: unknown[] = []
|
const out: unknown[] = []
|
||||||
const pairedToolCallIds = completeToolCallIds(messages)
|
const pairedToolCallIds = completeToolCallIds(messages)
|
||||||
|
|
||||||
|
|||||||
+91
-26
@@ -7,10 +7,12 @@
|
|||||||
|
|
||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
|
|
||||||
|
import { commandCodeErrorMessage, redactCommandCodeErrorText } from "./overflow.ts"
|
||||||
import {
|
import {
|
||||||
getApiKey,
|
getApiKey,
|
||||||
getEnvironmentInfo,
|
getEnvironmentInfo,
|
||||||
isRecord,
|
isRecord,
|
||||||
|
assertTextOnlyMessages,
|
||||||
mapFinishReason,
|
mapFinishReason,
|
||||||
messagesToCC,
|
messagesToCC,
|
||||||
numberValue,
|
numberValue,
|
||||||
@@ -36,10 +38,17 @@ import type {
|
|||||||
} from "./types.ts"
|
} from "./types.ts"
|
||||||
|
|
||||||
export * from "./converters.ts"
|
export * from "./converters.ts"
|
||||||
|
export * from "./overflow.ts"
|
||||||
export * from "./types.ts"
|
export * from "./types.ts"
|
||||||
|
|
||||||
export const DEFAULT_API_BASE = "https://api.commandcode.ai"
|
export const DEFAULT_API_BASE = "https://api.commandcode.ai"
|
||||||
export const COMMAND_CODE_CLI_VERSION = "0.29.0"
|
export const COMMAND_CODE_CLI_VERSION = "0.29.0"
|
||||||
|
/**
|
||||||
|
* The legacy /alpha/generate request path used by this provider has no
|
||||||
|
* documented image-part contract. Keep the advertised capability text-only
|
||||||
|
* until Command Code documents and tests image handling for this endpoint.
|
||||||
|
*/
|
||||||
|
export const COMMAND_CODE_INPUT_TYPES = ["text"] as const
|
||||||
|
|
||||||
const DEFAULT_GENERATE_MAX_TOKENS = 64_000
|
const DEFAULT_GENERATE_MAX_TOKENS = 64_000
|
||||||
const DEFAULT_MAX_RETRIES = 0
|
const DEFAULT_MAX_RETRIES = 0
|
||||||
@@ -194,6 +203,31 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
return function streamCommandCode(
|
||||||
model: ModelLike,
|
model: ModelLike,
|
||||||
context: ContextLike,
|
context: ContextLike,
|
||||||
@@ -418,9 +452,10 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "error": {
|
case "error": {
|
||||||
const errorRecord = isRecord(event.error) ? event.error : undefined
|
|
||||||
const message =
|
const message =
|
||||||
stringValue(errorRecord?.message) ?? stringValue(event.error) ?? "Stream error"
|
commandCodeErrorMessage(event.error) ??
|
||||||
|
commandCodeErrorMessage(event.message) ??
|
||||||
|
"Stream error"
|
||||||
output.stopReason = "error"
|
output.stopReason = "error"
|
||||||
output.errorMessage = message
|
output.errorMessage = message
|
||||||
throw new Error(message)
|
throw new Error(message)
|
||||||
@@ -430,10 +465,14 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
stream.push({ type: "start", partial: output })
|
stream.push({ type: "start", partial: output })
|
||||||
|
if (controller.signal.aborted) throw abortError("Aborted")
|
||||||
|
|
||||||
const workingDir = cwd()
|
const workingDir = cwd()
|
||||||
const threadId = uuid()
|
const threadId = uuid()
|
||||||
const reasoningEffort = mappedReasoningEffort(model, options)
|
const reasoningEffort = mappedReasoningEffort(model, options)
|
||||||
|
const timeoutMs = options?.timeoutMs
|
||||||
|
|
||||||
|
assertTextOnlyMessages(context.messages)
|
||||||
|
|
||||||
let body: unknown = {
|
let body: unknown = {
|
||||||
config: {
|
config: {
|
||||||
@@ -463,15 +502,23 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
|||||||
threadId,
|
threadId,
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextBody = await raceAbort(
|
const payloadController = new AbortController()
|
||||||
Promise.resolve(options?.onPayload?.(body, model)),
|
const onPayloadAbort = () => payloadController.abort()
|
||||||
controller.signal,
|
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
|
if (nextBody !== undefined) body = nextBody
|
||||||
|
|
||||||
const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES
|
const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES
|
||||||
const maxRetryDelayMs = effectiveMaxRetryDelayMs(options?.maxRetryDelayMs)
|
const maxRetryDelayMs = effectiveMaxRetryDelayMs(options?.maxRetryDelayMs)
|
||||||
const timeoutMs = options?.timeoutMs
|
|
||||||
const requestHeaders = {
|
const requestHeaders = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${apiKey}`,
|
Authorization: `Bearer ${apiKey}`,
|
||||||
@@ -505,6 +552,11 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
|||||||
}
|
}
|
||||||
const onOuterAbort = () => attemptController.abort()
|
const onOuterAbort = () => attemptController.abort()
|
||||||
controller.signal.addEventListener("abort", onOuterAbort, { once: true })
|
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 {
|
||||||
try {
|
try {
|
||||||
@@ -540,25 +592,38 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await raceAbort(
|
try {
|
||||||
Promise.resolve(
|
await raceAttempt(
|
||||||
options?.onResponse?.(
|
Promise.resolve(
|
||||||
{
|
options?.onResponse?.(
|
||||||
status: response.status,
|
{
|
||||||
headers: headersToRecord(response.headers),
|
status: response.status,
|
||||||
},
|
headers: headersToRecord(response.headers),
|
||||||
model,
|
},
|
||||||
|
model,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
controller.signal,
|
} catch (error: unknown) {
|
||||||
)
|
if (attemptTimedOut && attempt < maxRetries) continue retryLoop
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errBody = await raceAbort(
|
const errBody = await raceAttempt(response.text().catch(() => ""))
|
||||||
response.text().catch(() => ""),
|
let errorDetail: string | undefined
|
||||||
controller.signal,
|
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}: ${errBody.slice(0, 500)}`)
|
throw new Error(`Command Code API error ${response.status}: ${detail}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Read response stream ---
|
// --- Read response stream ---
|
||||||
@@ -639,9 +704,7 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
|||||||
output.errorMessage =
|
output.errorMessage =
|
||||||
reason === "aborted"
|
reason === "aborted"
|
||||||
? "Request aborted"
|
? "Request aborted"
|
||||||
: error instanceof Error
|
: redactCommandCodeErrorText(error instanceof Error ? error.message : String(error))
|
||||||
? error.message
|
|
||||||
: String(error)
|
|
||||||
stream.push({ type: "error", reason, error: output })
|
stream.push({ type: "error", reason, error: output })
|
||||||
stream.end()
|
stream.end()
|
||||||
} finally {
|
} finally {
|
||||||
@@ -668,7 +731,9 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
|||||||
model: model.id,
|
model: model.id,
|
||||||
usage: defaultUsage(),
|
usage: defaultUsage(),
|
||||||
stopReason: "error",
|
stopReason: "error",
|
||||||
errorMessage: error instanceof Error ? error.message : String(error),
|
errorMessage: redactCommandCodeErrorText(
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
),
|
||||||
timestamp: now(),
|
timestamp: now(),
|
||||||
}
|
}
|
||||||
stream.push({ type: "error", reason: "error", error: msg })
|
stream.push({ type: "error", reason: "error", error: msg })
|
||||||
|
|||||||
@@ -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,196 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,6 +10,8 @@ import { join } from "node:path"
|
|||||||
import { describe, it } from "node:test"
|
import { describe, it } from "node:test"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
assertTextOnlyMessages,
|
||||||
|
COMMAND_CODE_INPUT_TYPES,
|
||||||
getApiKey,
|
getApiKey,
|
||||||
getEnvironmentInfo,
|
getEnvironmentInfo,
|
||||||
mapFinishReason,
|
mapFinishReason,
|
||||||
@@ -20,6 +22,7 @@ import {
|
|||||||
toJsonSchema,
|
toJsonSchema,
|
||||||
toolsToJson,
|
toolsToJson,
|
||||||
} from "../src/core.ts"
|
} from "../src/core.ts"
|
||||||
|
import { redactCommandCodeErrorText } from "../src/overflow.ts"
|
||||||
|
|
||||||
import { objectAt } from "./helpers.ts"
|
import { objectAt } from "./helpers.ts"
|
||||||
|
|
||||||
@@ -90,6 +93,20 @@ describe("getApiKey()", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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()", () => {
|
describe("projectSlugFromPath()", () => {
|
||||||
it("matches the official CLI-style slug from an absolute working directory", () => {
|
it("matches the official CLI-style slug from an absolute working directory", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -100,13 +117,42 @@ describe("projectSlugFromPath()", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("text-only image handling", () => {
|
||||||
|
it("does not advertise image input capability", () => {
|
||||||
|
assert.deepEqual(COMMAND_CODE_INPUT_TYPES, ["text"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects image content instead of dropping it", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
assertTextOnlyMessages([
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "image", data: "base64-data", mimeType: "image/png" }],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
/does not support image content.*refusing to send it/i,
|
||||||
|
)
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
assertTextOnlyMessages([
|
||||||
|
{
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: "c1",
|
||||||
|
content: [{ type: "image", data: "base64-data", mimeType: "image/png" }],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
/does not support image content.*refusing to send it/i,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("textContent()", () => {
|
describe("textContent()", () => {
|
||||||
it("extracts and joins text blocks", () => {
|
it("extracts and joins text blocks", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
textContent({
|
textContent({
|
||||||
content: [
|
content: [
|
||||||
{ type: "text", text: "hello" },
|
{ type: "text", text: "hello" },
|
||||||
{ type: "image", data: "x" },
|
|
||||||
{ type: "text", text: "world" },
|
{ type: "text", text: "world" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@@ -114,6 +160,20 @@ describe("textContent()", () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("rejects mixed text and image content instead of dropping the image", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
textContent({
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "hello" },
|
||||||
|
{ type: "image", data: "x", mimeType: "image/png" },
|
||||||
|
{ type: "text", text: "world" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
/does not support image content.*refusing to send it/i,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it("handles empty or missing content", () => {
|
it("handles empty or missing content", () => {
|
||||||
assert.equal(textContent({ content: [] }), "")
|
assert.equal(textContent({ content: [] }), "")
|
||||||
assert.equal(textContent({}), "")
|
assert.equal(textContent({}), "")
|
||||||
@@ -177,6 +237,201 @@ describe("toJsonSchema()", () => {
|
|||||||
)
|
)
|
||||||
assert.deepEqual(toJsonSchema(undefined), {})
|
assert.deepEqual(toJsonSchema(undefined), {})
|
||||||
assert.deepEqual(toJsonSchema({ kind: "wat" }), {})
|
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" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -354,6 +354,34 @@ describe("streamCommandCode — successful streams", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("streamCommandCode — request serialization", () => {
|
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.*refusing to send it/i,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assert.equal(server.requestCount(), 0)
|
||||||
|
})
|
||||||
it("sends the expected request body and default headers", async () => {
|
it("sends the expected request body and default headers", async () => {
|
||||||
server.mockResponse({
|
server.mockResponse({
|
||||||
type: "success",
|
type: "success",
|
||||||
@@ -394,6 +422,7 @@ describe("streamCommandCode — request serialization", () => {
|
|||||||
assert.equal(objectAt(body, ["params", "model"]), "deepseek/deepseek-v4-flash")
|
assert.equal(objectAt(body, ["params", "model"]), "deepseek/deepseek-v4-flash")
|
||||||
assert.equal(objectAt(body, ["params", "stream"]), true)
|
assert.equal(objectAt(body, ["params", "stream"]), true)
|
||||||
assert.equal(objectAt(body, ["params", "max_tokens"]), 500)
|
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", "temperature"]), 0.3)
|
||||||
assert.equal(objectAt(body, ["params", "system"]), "You are a test assistant.")
|
assert.equal(objectAt(body, ["params", "system"]), "You are a test assistant.")
|
||||||
assert.equal(objectAt(body, ["memory"]), null)
|
assert.equal(objectAt(body, ["memory"]), null)
|
||||||
@@ -552,6 +581,51 @@ describe("streamCommandCode — request serialization", () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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 () => {
|
it("runs onPayload and onResponse hooks", async () => {
|
||||||
server.mockResponse({
|
server.mockResponse({
|
||||||
type: "success",
|
type: "success",
|
||||||
|
|||||||
Reference in New Issue
Block a user