From a0f2b4e34fed165c6348312c1f4bc0e71e686bf9 Mon Sep 17 00:00:00 2001 From: Choti Wongbussakorn <126886556+Chewji9875@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:25:56 +0700 Subject: [PATCH] fix(command-code): fallback to /alpha/generate for Go plan without Provider API access Go plan keys are rejected on the documented /provider/v1/chat/completions endpoint (403 upgrade_required) yet succeed on the CLI /alpha/generate endpoint. Add transparent fallback in both the executor and the API key validator so validation and chat both succeed for Go plan, preserving the new provider API for Provider tier and falling back only on 403/404. Fixes dashboard Invalid API Key on command-code login for Go plan accounts. --- open-sse/executors/commandCode.ts | 961 ++++++++++++++++-- src/lib/providers/validation/openaiFormat.ts | 64 +- tests/unit/command-code-executor.test.ts | 141 ++- .../provider-validation-specialty.test.ts | 46 + 4 files changed, 1134 insertions(+), 78 deletions(-) diff --git a/open-sse/executors/commandCode.ts b/open-sse/executors/commandCode.ts index a1023fc80cd9..be12cae31620 100644 --- a/open-sse/executors/commandCode.ts +++ b/open-sse/executors/commandCode.ts @@ -1,3 +1,6 @@ +import { randomUUID } from "node:crypto"; + +import { isVisionModelId } from "@/shared/constants/visionModels"; import { REGISTRY } from "../config/providerRegistry.ts"; import { BaseExecutor, @@ -8,37 +11,63 @@ import { type JsonRecord = Record; -// Defensive server-side ceiling for a CLIENT-SUPPLIED max_tokens. The official -// /provider/v1/chat/completions endpoint (documented OpenAI-format surface) is -// the successor to the CLI-only /alpha/generate endpoint, which rejected any -// params.max_tokens > 200_000 with a 400. We only clamp a client-supplied value -// down; we never fabricate this number for requests that omit the field (see -// clampMaxTokens / buildOpenAiBody). +export const COMMAND_CODE_VERSION = process.env.COMMAND_CODE_VERSION?.trim() || "1.15.1"; + +// Defensive server-side ceiling for a CLIENT-SUPPLIED max_tokens: +// any request with params.max_tokens > 200_000 is rejected with a 400 +// "Too big: expected number to be <=200000 at params.max_tokens". We only clamp +// a client-supplied value down; we never fabricate this number for requests +// that omit the field. const MAX_COMMAND_CODE_TOKENS = 200_000; +const encoder = new TextEncoder(); function isRecord(value: unknown): value is JsonRecord { return typeof value === "object" && value !== null && !Array.isArray(value); } +function asRecordArray(value: unknown): JsonRecord[] { + return Array.isArray(value) ? value.filter(isRecord) : []; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + function numberValue(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } -// Clamp a client-supplied max_tokens to the endpoint ceiling, mirroring the -// provider-driven clamp in antigravity.ts: we only intervene when the value is -// present, positive AND would otherwise be rejected (> MAX_COMMAND_CODE_TOKENS). -// A valid value is returned floored; anything absent, non-numeric or non-positive -// returns undefined so the caller can OMIT the field entirely and let the -// provider's upstream apply the model's own native default (rather than us -// inventing a number). A non-positive value such as Zoo Code's max_tokens:-1 -// ("let the server choose") must be omitted, NOT forced to 1 — the old -// Math.max(1,...) truncated output to a single token (#5166). +function recordOrEmpty(value: unknown): JsonRecord { + if (isRecord(value)) return value; + if (typeof value === "string" && value.trim()) { + try { + const parsed: unknown = JSON.parse(value); + if (isRecord(parsed)) return parsed; + } catch (error) { + console.warn( + "[commandCode] tool arg parse failed:", + error instanceof Error ? error.message : String(error) + ); + } + } + return {}; +} + function clampMaxTokens(value: unknown): number | undefined { const numeric = numberValue(value); if (numeric === undefined || numeric <= 0) return undefined; return Math.min(Math.floor(numeric), MAX_COMMAND_CODE_TOKENS); } +const COMMAND_CODE_PASSTHROUGH_FIELDS = [ + "reasoning_effort", + "reasoning", + "thinking", + "effort", + "output_config", + "extra_body", +] as const; + /** * Command Code serves most models under a vendor-prefixed wire id (e.g. * `xiaomi/mimo-v2.5`, `deepseek/deepseek-v4-pro`, `moonshotai/Kimi-K2.6`). @@ -49,17 +78,10 @@ function clampMaxTokens(value: unknown): number | undefined { * passes through untouched. Kept minimal and doc-backed. */ const COMMAND_CODE_BARE_MODEL_VENDOR_PREFIX: Readonly> = { - // Xiaomi MiMo V2.5 — a CC-served vision model not in the registry. "mimo-v2.5": "xiaomi/mimo-v2.5", "mimo-v2.5-pro": "xiaomi/mimo-v2.5-pro", }; -/** - * Normalize an incoming model id to the wire form Command Code's provider API - * accepts. Strips a leading provider prefix (`command-code/` / `cmd/`) that the - * pipeline may have resolved, then maps known bare ids to their - * vendor-prefixed form (see above). - */ function normalizeCommandCodeWireModel(model: string): string { const trimmed = String(model || "").trim(); if (!trimmed) return trimmed; @@ -68,27 +90,13 @@ function normalizeCommandCodeWireModel(model: string): string { return COMMAND_CODE_BARE_MODEL_VENDOR_PREFIX[bare] ?? bare; } -/** - * Build a flat OpenAI chat.completions request body for the official - * /provider/v1/chat/completions endpoint. The incoming body is already the - * standard OpenAI chat.completions shape (registry `format: "openai"`), so this - * is a passthrough that: normalizes the wire model id, forces the stream flag - * to match the caller's expectation, clamps max_tokens, and lets reasoning / - * payload-rule passthrough fields flow through untouched. No CLI envelope - * (config/memory/taste/skills/permissionMode) and no CLI-shaped message - * conversion here — /provider/v1 is the documented, standard API. - */ -function buildOpenAiBody( - model: string, - body: unknown, - stream: boolean -): { body: JsonRecord } { +// ── OpenAi Flat Body Builder (/provider/v1/chat/completions) ───────────────── + +function buildOpenAiBody(model: string, body: unknown, stream: boolean): { body: JsonRecord } { const input = isRecord(body) ? { ...(body as JsonRecord) } : {}; const resolvedModel = normalizeCommandCodeWireModel( - typeof input.model === "string" && input.model.trim().length > 0 - ? input.model - : model + typeof input.model === "string" && input.model.trim().length > 0 ? input.model : model ); const out: JsonRecord = { @@ -97,10 +105,6 @@ function buildOpenAiBody( stream: stream === true, }; - // Forward max_tokens only when the client actually supplied a positive value - // (clamped to the endpoint ceiling). Omitting it lets the provider's upstream - // apply the model's own native default; a non-positive value such as -1 - // ("let the server choose") must be omitted, NOT coerced to 1 (#5166). const maxTokens = clampMaxTokens(input.max_tokens ?? input.max_completion_tokens); delete out.max_tokens; delete out.max_completion_tokens; @@ -111,6 +115,770 @@ function buildOpenAiBody( return { body: out }; } +// ── CLI Body Builder & Converters (/alpha/generate fallback) ───────────────── + +function toolCallArgumentsString(value: unknown): string { + if (isRecord(value)) return JSON.stringify(value); + if (typeof value === "string" && value.trim()) { + try { + const parsed: unknown = JSON.parse(value); + if (isRecord(parsed)) return value; + } catch { + return "{}"; + } + return "{}"; + } + return JSON.stringify(recordOrEmpty(value)); +} + +const COMMAND_CODE_RESERVED_TOOL_NAMES = new Set(["tool_search"]); + +function wireToolName(clientName: string, toolNameMap: Map): string { + if (COMMAND_CODE_RESERVED_TOOL_NAMES.has(clientName)) { + const wire = `omniroute_${clientName}`; + toolNameMap.set(wire, clientName); + return wire; + } + return clientName; +} + +function clientToolName(wireName: string, toolNameMap: Map): string { + return toolNameMap.get(wireName) ?? wireName; +} + +function normalizeContentText(content: unknown): string { + if (typeof content === "string") return content; + return asRecordArray(content) + .filter((part) => part.type === "text") + .map((part) => stringValue(part.text) || "") + .join("\n"); +} + +const CC_VISION_MODEL_PATTERNS: readonly RegExp[] = [ + /kimi-k2/i, + /qwen3\.\d/i, + /step-?3/i, + /claude-fable/i, + /gpt-5/i, + /fugu/i, +]; + +function isCommandCodeVisionModel(model?: string | null): boolean { + if (!model) return false; + if (/(?:^|\/)mimo-v2\.5-pro$/i.test(model)) return false; + if (/(?:^|\/)mimo-v2\.5$/i.test(model)) return true; + if (/(?:^|\/)mimo-v2-omni$/i.test(model)) return true; + if (CC_VISION_MODEL_PATTERNS.some((pattern) => pattern.test(model))) return true; + return isVisionModelId(model); +} + +function extractImageUrl(part: JsonRecord): string | undefined { + if (part.type === "image") { + const direct = stringValue(part.image); + if (direct) return direct; + + const source = isRecord(part.source) ? part.source : null; + if (source) { + if (source.type === "base64") { + const mediaType = stringValue(source.media_type) || "image/png"; + const data = stringValue(source.data); + if (data) return `data:${mediaType};base64,${data}`; + } + if (source.type === "url") { + const url = stringValue(source.url); + if (url) return url; + } + } + return undefined; + } + if (part.type === "image_url") { + if (isRecord(part.image_url)) return stringValue(part.image_url.url); + return stringValue(part.image_url); + } + return undefined; +} + +function convertUserContentParts(content: unknown, isVisionModel: boolean): string | unknown[] { + if (!isVisionModel || typeof content === "string") { + return normalizeContentText(content); + } + + const parts: unknown[] = []; + for (const part of asRecordArray(content)) { + if (part.type === "text") { + const text = stringValue(part.text); + if (text) parts.push({ type: "text", text }); + continue; + } + const imgUrl = extractImageUrl(part); + if (imgUrl) { + parts.push({ type: "image", image: imgUrl }); + continue; + } + } + + if (parts.length === 0) parts.push({ type: "text", text: "" }); + return parts; +} + +function convertTools(tools: unknown, toolNameMap: Map): unknown[] { + return asRecordArray(tools).map((tool) => { + const fn = isRecord(tool.function) ? tool.function : tool; + return { + type: "function", + name: wireToolName(stringValue(fn.name) || "", toolNameMap), + description: stringValue(fn.description) || "", + input_schema: isRecord(fn.parameters) ? fn.parameters : {}, + }; + }); +} + +function buildToolCallMetadata( + messages: JsonRecord[], + toolNameMap: Map +): { + pairedToolCallIds: Set; + toolCallNames: Map; + toolCallArgs: Map; +} { + const callIds = new Set(); + const resultIds = new Set(); + const toolCallNames = new Map(); + const toolCallArgs = new Map(); + + for (const message of messages) { + if (message.role === "assistant") { + for (const call of asRecordArray(message.tool_calls)) { + const id = stringValue(call.id); + if (id) { + callIds.add(id); + const fn = isRecord(call.function) ? call.function : {}; + const name = stringValue(fn.name) || stringValue(call.name); + if (name) toolCallNames.set(id, wireToolName(name, toolNameMap)); + toolCallArgs.set(id, toolCallArgumentsString(fn.arguments)); + } + } + } else if (message.role === "tool") { + const id = stringValue(message.tool_call_id); + if (id) resultIds.add(id); + } + } + + const pairedToolCallIds = new Set([...callIds].filter((id) => resultIds.has(id))); + return { pairedToolCallIds, toolCallNames, toolCallArgs }; +} + +function convertMessages( + messages: unknown, + model?: string | null, + toolNameMap?: Map +): { system: string; messages: unknown[] } { + const source = asRecordArray(messages); + const { pairedToolCallIds, toolCallNames, toolCallArgs } = buildToolCallMetadata( + source, + toolNameMap ?? new Map() + ); + const out: unknown[] = []; + const system: string[] = []; + const isVision = isCommandCodeVisionModel(model); + + for (const message of source) { + const role = stringValue(message.role); + if (role === "system" || role === "developer") { + const text = normalizeContentText(message.content); + if (text) system.push(text); + continue; + } + + if (role === "user") { + out.push({ role: "user", content: convertUserContentParts(message.content, isVision) }); + continue; + } + + if (role === "assistant") { + const parts: unknown[] = []; + const text = normalizeContentText(message.content); + if (text) parts.push({ type: "text", text }); + + for (const call of asRecordArray(message.tool_calls)) { + const id = stringValue(call.id) || ""; + if (!id || !pairedToolCallIds.has(id)) continue; + const fn = isRecord(call.function) ? call.function : {}; + const parsedInput = recordOrEmpty(fn.arguments); + parts.push({ + type: "tool-call", + toolCallId: id, + toolName: wireToolName( + stringValue(fn.name) || stringValue(call.name) || "unknown", + toolNameMap ?? new Map() + ), + input: parsedInput, + arguments: toolCallArgumentsString(fn.arguments), + }); + } + + if (parts.length > 0) out.push({ role: "assistant", content: parts }); + continue; + } + + if (role === "tool") { + const toolCallId = stringValue(message.tool_call_id) || ""; + if (!toolCallId || !pairedToolCallIds.has(toolCallId)) continue; + const toolName = wireToolName( + stringValue(message.name) || toolCallNames.get(toolCallId) || "unknown", + toolNameMap ?? new Map() + ); + out.push({ + role: "tool", + content: [ + { + type: "tool-result", + toolCallId, + toolName, + arguments: toolCallArgs.get(toolCallId) ?? "{}", + output: { type: "text", value: normalizeContentText(message.content) }, + }, + ], + }); + } + } + + return { system: system.join("\n\n"), messages: out }; +} + +function buildCommandCodeCliBody( + model: string, + body: unknown, + _stream = false +): { body: JsonRecord; toolNameMap: Map } { + const input = isRecord(body) ? body : {}; + const toolNameMap = new Map(); + + const resolvedModel = normalizeCommandCodeWireModel( + typeof input.model === "string" && input.model.trim().length > 0 ? input.model : model + ); + + const converted = convertMessages(input.messages, resolvedModel, toolNameMap); + const explicitSystem = typeof input.system === "string" ? input.system : ""; + const system = [converted.system, explicitSystem].filter(Boolean).join("\n\n"); + + const params: JsonRecord = { + model: resolvedModel, + messages: converted.messages, + tools: convertTools(input.tools, toolNameMap), + system, + stream: true, + }; + + const maxTokens = clampMaxTokens(input.max_tokens ?? input.max_completion_tokens); + if (maxTokens !== undefined) { + params.max_tokens = maxTokens; + } + + for (const field of COMMAND_CODE_PASSTHROUGH_FIELDS) { + const value = input[field]; + if (value !== undefined && value !== null) { + params[field] = value; + } + } + + return { + body: { + config: { + workingDir: "/workspace", + date: new Date().toISOString().slice(0, 10), + environment: "external", + structure: [], + isGitRepo: false, + currentBranch: "", + mainBranch: "", + gitStatus: "", + recentCommits: [], + }, + memory: "", + taste: "", + skills: "", + permissionMode: "standard", + params, + }, + toolNameMap, + }; +} + +function parseStreamLine(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 { + return JSON.parse(trimmed); + } catch (error) { + console.warn( + "[commandCode] stream line parse failed:", + error instanceof Error ? error.message : String(error) + ); + return undefined; + } +} + +function mapFinishReason(reason: unknown): "stop" | "length" | "tool_calls" { + if (reason === "tool-calls" || reason === "tool_calls" || reason === "toolUse") + return "tool_calls"; + if ( + reason === "length" || + reason === "max_tokens" || + reason === "max-tokens" || + reason === "max_output_tokens" + ) { + return "length"; + } + return "stop"; +} + +function chatCompletionChunk( + id: string, + model: string, + delta: JsonRecord, + finishReason: unknown = null +) { + return { + id, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, delta, finish_reason: finishReason }], + }; +} + +function sse(data: unknown): Uint8Array { + return encoder.encode(`data: ${JSON.stringify(data)}\n\n`); +} + +type AggregateState = { + content: string; + reasoning: string; + toolCalls: JsonRecord[]; + finishReason: "stop" | "length" | "tool_calls"; + usage: JsonRecord | null; +}; + +function firstRecord(record: JsonRecord, keys: readonly string[]): JsonRecord { + for (const key of keys) { + const value = record[key]; + if (isRecord(value)) return value; + } + return {}; +} + +function firstNumber(record: JsonRecord, keys: readonly string[]): number | undefined { + for (const key of keys) { + const value = numberValue(record[key]); + if (value !== undefined) return value; + } + return undefined; +} + +function mergeCommandCodeUsage(previous: JsonRecord | null, next: unknown): JsonRecord | null { + if (!isRecord(next)) return previous; + + const merged: JsonRecord = { ...(previous || {}), ...next }; + for (const key of [ + "inputTokenDetails", + "input_token_details", + "input_tokens_details", + "prompt_tokens_details", + "outputTokenDetails", + "output_token_details", + "output_tokens_details", + "completion_tokens_details", + "reasoningTokenDetails", + "reasoning_token_details", + ]) { + const before = isRecord(previous?.[key]) ? previous[key] : {}; + const after = isRecord(next[key]) ? next[key] : {}; + if (Object.keys(before).length > 0 || Object.keys(after).length > 0) { + merged[key] = { ...before, ...after }; + } + } + return merged; +} + +function rememberCommandCodeUsage(state: AggregateState, event: JsonRecord): void { + const usage = + event.type === "finish-step" + ? (event.usage ?? event.totalUsage) + : (event.totalUsage ?? event.usage); + state.usage = mergeCommandCodeUsage(state.usage, usage); +} + +function applyEventToAggregate( + event: JsonRecord, + state: AggregateState, + toolNameMap: Map +): void { + rememberCommandCodeUsage(state, event); + + switch (event.type) { + case "text-delta": + state.content += stringValue(event.text) || ""; + break; + case "reasoning-delta": + state.reasoning += stringValue(event.text) || ""; + break; + case "tool-call": { + const args = recordOrEmpty(event.input ?? event.args ?? event.arguments); + state.toolCalls.push({ + id: stringValue(event.toolCallId) || stringValue(event.id) || randomUUID(), + type: "function", + function: { + name: clientToolName( + stringValue(event.toolName) || stringValue(event.name) || "", + toolNameMap + ), + arguments: JSON.stringify(args), + }, + }); + break; + } + case "finish-step": + break; + case "finish": + state.finishReason = mapFinishReason(event.finishReason); + break; + } +} + +function applyEventToAggregateOrThrow( + event: JsonRecord, + state: AggregateState, + toolNameMap: Map +): void { + if (event.type === "error") { + const error = isRecord(event.error) ? event.error : {}; + throw new Error( + stringValue(error.message) || stringValue(event.error) || "Command Code stream error" + ); + } + + applyEventToAggregate(event, state, toolNameMap); +} + +function usageFromCommandCode(usage: JsonRecord | null) { + if (!usage) return undefined; + const inputDetails = firstRecord(usage, [ + "inputTokenDetails", + "input_token_details", + "input_tokens_details", + "prompt_tokens_details", + ]); + const outputDetails = firstRecord(usage, [ + "outputTokenDetails", + "output_token_details", + "output_tokens_details", + "completion_tokens_details", + ]); + const reasoningDetails = firstRecord(usage, [ + "reasoningTokenDetails", + "reasoning_token_details", + "reasoning_tokens_details", + ]); + const cacheRead = + firstNumber(usage, [ + "cachedInputTokens", + "cached_input_tokens", + "cacheReadInputTokens", + "cache_read_input_tokens", + "cacheReadTokens", + "cache_read_tokens", + "cached_tokens", + ]) ?? + firstNumber(inputDetails, [ + "cachedTokens", + "cached_tokens", + "cacheReadTokens", + "cache_read_tokens", + ]); + const noCache = firstNumber(inputDetails, ["noCacheTokens", "no_cache_tokens"]); + const prompt = + firstNumber(usage, ["inputTokens", "input_tokens", "promptTokens", "prompt_tokens"]) ?? + (noCache ?? 0) + (cacheRead ?? 0); + const reasoning = + firstNumber(usage, ["reasoningTokens", "reasoning_tokens"]) ?? + firstNumber(outputDetails, ["reasoningTokens", "reasoning_tokens"]) ?? + firstNumber(reasoningDetails, ["reasoningTokens", "reasoning_tokens"]); + const textOutput = firstNumber(outputDetails, ["textTokens", "text_tokens"]); + const completion = + firstNumber(usage, [ + "outputTokens", + "output_tokens", + "completionTokens", + "completion_tokens", + ]) ?? (textOutput ?? 0) + (reasoning ?? 0); + const total = firstNumber(usage, ["totalTokens", "total_tokens"]) ?? prompt + completion; + const result: JsonRecord = { + prompt_tokens: prompt, + prompt_tokens_details: { cached_tokens: cacheRead ?? 0 }, + completion_tokens: completion, + completion_tokens_details: { reasoning_tokens: reasoning ?? 0 }, + total_tokens: total, + }; + if (cacheRead !== undefined && cacheRead > 0) result.cache_read_input_tokens = cacheRead; + if (noCache !== undefined && noCache > 0) result.no_cache_tokens = noCache; + if (reasoning !== undefined && reasoning > 0) result.reasoning_tokens = reasoning; + return result; +} + +function createStreamResponse( + upstream: Response, + model: string, + signal?: AbortSignal | null, + toolNameMap: Map = new Map() +): Response { + const id = `chatcmpl-${randomUUID()}`; + const reader = upstream.body?.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let sentRole = false; + let sentContent = false; + let closed = false; + const state: AggregateState = { + content: "", + reasoning: "", + toolCalls: [], + finishReason: "stop", + usage: null, + }; + + const stream = new ReadableStream({ + start(controller) { + if (!reader) { + controller.error(new Error("Command Code response missing body")); + return; + } + + const abort = () => { + closed = true; + reader.cancel().catch(() => undefined); + controller.error(new DOMException("The operation was aborted", "AbortError")); + }; + signal?.addEventListener("abort", abort, { once: true }); + + const emitEvent = (event: unknown) => { + if (!isRecord(event) || closed) return; + rememberCommandCodeUsage(state, event); + if (!sentRole) { + sentRole = true; + controller.enqueue(sse(chatCompletionChunk(id, model, { role: "assistant" }))); + } + + switch (event.type) { + case "text-delta": { + const text = stringValue(event.text) || ""; + if (text) { + sentContent = true; + controller.enqueue(sse(chatCompletionChunk(id, model, { content: text }))); + } + state.content += text; + break; + } + case "reasoning-delta": { + const text = stringValue(event.text) || ""; + if (text) { + controller.enqueue(sse(chatCompletionChunk(id, model, { reasoning_content: text }))); + state.reasoning += text; + } + break; + } + case "tool-call": { + const index = state.toolCalls.length; + const args = recordOrEmpty(event.input ?? event.args ?? event.arguments); + const toolCall = { + id: stringValue(event.toolCallId) || stringValue(event.id) || randomUUID(), + type: "function", + function: { + name: clientToolName( + stringValue(event.toolName) || stringValue(event.name) || "", + toolNameMap + ), + arguments: JSON.stringify(args), + }, + }; + state.toolCalls.push(toolCall); + controller.enqueue( + sse(chatCompletionChunk(id, model, { tool_calls: [{ index, ...toolCall }] })) + ); + break; + } + case "reasoning-end": + break; + case "finish-step": + break; + case "finish": { + state.finishReason = mapFinishReason(event.finishReason); + if (!sentContent && state.reasoning && state.toolCalls.length === 0) { + controller.enqueue(sse(chatCompletionChunk(id, model, { content: state.reasoning }))); + } + controller.enqueue(sse(chatCompletionChunk(id, model, {}, state.finishReason))); + const usagePayload = usageFromCommandCode(state.usage); + if (usagePayload) { + controller.enqueue( + sse({ + id, + object: "chat.completion.chunk", + model, + usage: usagePayload, + choices: [], + }) + ); + } + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + closed = true; + controller.close(); + reader.cancel().catch(() => undefined); + break; + } + case "error": { + const error = isRecord(event.error) ? event.error : {}; + throw new Error( + stringValue(error.message) || stringValue(event.error) || "Command Code stream error" + ); + } + } + }; + + const pump = async () => { + try { + for (;;) { + if (closed) return; + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) emitEvent(parseStreamLine(line)); + } + if (buffer.trim()) emitEvent(parseStreamLine(buffer)); + if (!closed) { + if (!sentRole) + controller.enqueue(sse(chatCompletionChunk(id, model, { role: "assistant" }))); + if (!sentContent && state.reasoning && state.toolCalls.length === 0) { + controller.enqueue(sse(chatCompletionChunk(id, model, { content: state.reasoning }))); + } + controller.enqueue(sse(chatCompletionChunk(id, model, {}, state.finishReason))); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + } + } catch (error) { + controller.error(error); + } finally { + signal?.removeEventListener("abort", abort); + try { + reader.releaseLock(); + } catch (error) { + console.warn( + "[commandCode] reader releaseLock failed:", + error instanceof Error ? error.message : String(error) + ); + } + } + }; + + pump(); + }, + cancel() { + closed = true; + return reader?.cancel(); + }, + }); + + return new Response(stream, { + status: 200, + headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache" }, + }); +} + +async function createJsonResponse( + upstream: Response, + model: string, + signal?: AbortSignal | null, + toolNameMap: Map = new Map() +): Promise { + const reader = upstream.body?.getReader(); + if (!reader) throw new Error("Command Code response missing body"); + + const decoder = new TextDecoder(); + let buffer = ""; + const state: AggregateState = { + content: "", + reasoning: "", + toolCalls: [], + finishReason: "stop", + usage: null, + }; + + try { + for (;;) { + if (signal?.aborted) throw new DOMException("The operation was aborted", "AbortError"); + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + const event = parseStreamLine(line); + if (!isRecord(event)) continue; + applyEventToAggregateOrThrow(event, state, toolNameMap); + } + } + if (buffer.trim()) { + const event = parseStreamLine(buffer); + if (isRecord(event)) applyEventToAggregateOrThrow(event, state, toolNameMap); + } + } finally { + try { + await reader.cancel(); + } catch (error) { + console.warn( + "[commandCode] reader cancel failed:", + error instanceof Error ? error.message : String(error) + ); + } + try { + reader.releaseLock(); + } catch (error) { + console.warn( + "[commandCode] reader releaseLock failed:", + error instanceof Error ? error.message : String(error) + ); + } + } + + const message: JsonRecord = { role: "assistant", content: state.content }; + if (!state.content && state.reasoning && state.toolCalls.length === 0) { + message.content = state.reasoning; + } + if (state.reasoning) message.reasoning_content = state.reasoning; + if (state.toolCalls.length > 0) message.tool_calls = state.toolCalls; + + const payload: JsonRecord = { + id: `chatcmpl-${randomUUID()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, message, finish_reason: state.finishReason }], + }; + const usage = usageFromCommandCode(state.usage); + if (usage) payload.usage = usage; + + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +// ── CommandCodeExecutor ────────────────────────────────────────────────────── + export class CommandCodeExecutor extends BaseExecutor { constructor(provider = "command-code") { super(provider, REGISTRY["command-code"]); @@ -121,10 +889,19 @@ export class CommandCodeExecutor extends BaseExecutor { return `${baseUrl}${this.config.chatPath || "/provider/v1/chat/completions"}`; } + buildCliUrl() { + const baseUrl = (this.config.baseUrl || "https://api.commandcode.ai").replace(/\/$/, ""); + return `${baseUrl}/alpha/generate`; + } + async execute({ model, body, stream, credentials, signal, upstreamExtraHeaders }: ExecuteInput) { const apiKey = credentials?.apiKey || credentials?.accessToken; if (!apiKey) throw new Error("Command Code API key required"); + const sanitizedBody = sanitizeReasoningEffortForProvider(body, this.provider, model); + const { body: transformedBody } = buildOpenAiBody(model, sanitizedBody, stream); + const url = this.buildUrl(); + const headers: Record = { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, @@ -132,14 +909,6 @@ export class CommandCodeExecutor extends BaseExecutor { }; mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders); - // The combo/single-model dispatch boundary does not always run - // sanitizeRequestForResolvedTarget before reaching this executor (combo - // path), and Command Code rejects unsupported reasoning_effort values - // outright. Sanitize here — the executor is the last line of defense for - // the wire body. - const sanitizedBody = sanitizeReasoningEffortForProvider(body, this.provider, model); - const { body: transformedBody } = buildOpenAiBody(model, sanitizedBody, stream); - const url = this.buildUrl(); const upstream = await fetch(url, { method: "POST", headers, @@ -147,26 +916,76 @@ export class CommandCodeExecutor extends BaseExecutor { signal: signal || undefined, }); - if (!upstream.ok) { - const errorText = await upstream.text().catch(() => { - console.warn("[commandCode] upstream text failed"); - return ""; - }); - return { - response: new Response(errorText || `Command Code API error ${upstream.status}`, { - status: upstream.status, - statusText: upstream.statusText, - headers: upstream.headers, - }), - url, - headers, - transformedBody, + if (upstream.ok) { + return { response: upstream, url, headers, transformedBody }; + } + + // Fallback: If /provider/v1/chat/completions returns 403 (e.g. Go plan without Provider + // API access) or 404, fallback to /alpha/generate (CLI endpoint). + if (upstream.status === 403 || upstream.status === 404) { + const cliUrl = this.buildCliUrl(); + const cliHeaders: Record = { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + "x-command-code-version": COMMAND_CODE_VERSION, + "x-cli-environment": "external", + "x-project-slug": "pi-cc", + "x-taste-learning": "false", + "x-co-flag": "false", + "x-session-id": randomUUID(), }; + mergeUpstreamExtraHeaders(cliHeaders, upstreamExtraHeaders); + + const { body: cliTransformedBody, toolNameMap } = buildCommandCodeCliBody( + model, + sanitizedBody, + stream + ); + + const cliUpstream = await fetch(cliUrl, { + method: "POST", + headers: cliHeaders, + body: JSON.stringify(cliTransformedBody), + signal: signal || undefined, + }); + + if (!cliUpstream.ok) { + const errorText = await cliUpstream.text().catch(() => { + console.warn("[commandCode] cli upstream text failed"); + return ""; + }); + return { + response: new Response(errorText || `Command Code API error ${cliUpstream.status}`, { + status: cliUpstream.status, + statusText: cliUpstream.statusText, + headers: cliUpstream.headers, + }), + url: cliUrl, + headers: cliHeaders, + transformedBody: cliTransformedBody, + }; + } + + const response = stream + ? createStreamResponse(cliUpstream, model, signal, toolNameMap) + : await createJsonResponse(cliUpstream, model, signal, toolNameMap); + + return { response, url: cliUrl, headers: cliHeaders, transformedBody: cliTransformedBody }; } - // The /provider/v1/chat/completions endpoint returns standard OpenAI-format - // SSE (stream) or JSON (non-stream) straight through, so the upstream - // Response passes through untouched — no AI-SDK/CLI event re-parsing needed. - return { response: upstream, url, headers, transformedBody }; + const errorText = await upstream.text().catch(() => { + console.warn("[commandCode] upstream text failed"); + return ""; + }); + return { + response: new Response(errorText || `Command Code API error ${upstream.status}`, { + status: upstream.status, + statusText: upstream.statusText, + headers: upstream.headers, + }), + url, + headers, + transformedBody, + }; } -} \ No newline at end of file +} diff --git a/src/lib/providers/validation/openaiFormat.ts b/src/lib/providers/validation/openaiFormat.ts index 8a9fc31e979f..353e8f67c48c 100644 --- a/src/lib/providers/validation/openaiFormat.ts +++ b/src/lib/providers/validation/openaiFormat.ts @@ -1,7 +1,9 @@ // OpenAI/Gemini-format + Bedrock provider key validators (bedrock, openai-like, command-code, gemini-like, openai-compatible). // Extracted from validation.ts (god-file decomposition) — top-level functions; behavior is // byte-identical to the original inline defs. +import { randomUUID } from "node:crypto"; import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts"; +import { COMMAND_CODE_VERSION } from "@omniroute/open-sse/executors/commandCode.ts"; import { discoverBedrockNativeModels, isBedrockNativeApiError, @@ -202,7 +204,7 @@ export async function validateCommandCodeProvider({ apiKey, providerSpecificData entry?.models?.find((model) => model.id === "deepseek/deepseek-v4-flash")?.id || "deepseek/deepseek-v4-flash"; - return validateDirectChatProvider({ + const result = await validateDirectChatProvider({ url, providerSpecificData, headers: { @@ -217,6 +219,66 @@ export async function validateCommandCodeProvider({ apiKey, providerSpecificData max_tokens: 1, }, }); + + if (result.valid) { + return result; + } + + // Fallback: Accounts on the Go plan receive 403 on /provider/v1/chat/completions + // because API access is restricted to Provider tier. Fallback to probing /alpha/generate. + try { + const alphaUrl = `${baseUrl}/alpha/generate`; + const alphaHeaders: Record = { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + "x-command-code-version": COMMAND_CODE_VERSION, + "x-cli-environment": "external", + "x-project-slug": "pi-cc", + "x-taste-learning": "false", + "x-co-flag": "false", + "x-session-id": randomUUID(), + }; + applyCustomUserAgent(alphaHeaders, providerSpecificData); + + const alphaBody = { + config: { environment: "external" }, + permissionMode: "standard", + skills: "", + params: { + model: validationModelId, + stream: true, + max_tokens: 1, + messages: [{ role: "user", content: "test" }], + }, + }; + + const alphaRes = await validationWrite(alphaUrl, { + method: "POST", + headers: alphaHeaders, + body: JSON.stringify(alphaBody), + }); + + if (alphaRes.ok) { + return { valid: true, error: null, method: "command_code_alpha" }; + } + + if (alphaRes.status === 401 || alphaRes.status === 403) { + return { valid: false, error: "Invalid API key" }; + } + + // 400 (e.g. insufficient credits), 422, 429 indicates key is authentic and recognized + if (alphaRes.status === 400 || alphaRes.status === 422 || alphaRes.status === 429) { + return { valid: true, error: null, method: "command_code_alpha" }; + } + + if (alphaRes.status >= 500) { + return { valid: false, error: `Provider unavailable (${alphaRes.status})` }; + } + } catch (error: any) { + return toValidationErrorResult(error); + } + + return result; } // HuggingFace fine-grained Inference-Provider tokens are valid even when diff --git a/tests/unit/command-code-executor.test.ts b/tests/unit/command-code-executor.test.ts index 14cb0ae5a67c..247aab2dea51 100644 --- a/tests/unit/command-code-executor.test.ts +++ b/tests/unit/command-code-executor.test.ts @@ -232,7 +232,7 @@ test("Command Code executor passes the upstream OpenAI SSE stream through untouc }); }; - const { response } = (await getExecutor("command-code")).execute({ + const { response } = await (await getExecutor("command-code")).execute({ model: "gpt-5.4", stream: true, credentials: { apiKey: "cc_test_key" }, @@ -254,7 +254,9 @@ test("Command Code executor passes the upstream OpenAI JSON through untouched (n id: "chatcmpl-1", object: "chat.completion", model: "gpt-5.4-mini", - choices: [{ index: 0, message: { role: "assistant", content: "Hello" }, finish_reason: "stop" }], + choices: [ + { index: 0, message: { role: "assistant", content: "Hello" }, finish_reason: "stop" }, + ], usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 }, }; let capturedStreamFlag: unknown = null; @@ -266,7 +268,7 @@ test("Command Code executor passes the upstream OpenAI JSON through untouched (n }); }; - const { response } = (await getExecutor("command-code")).execute({ + const { response } = await (await getExecutor("command-code")).execute({ model: "gpt-5.4-mini", stream: false, credentials: { apiKey: "cc_test_key" }, @@ -279,7 +281,7 @@ test("Command Code executor passes the upstream OpenAI JSON through untouched (n test("Command Code executor surfaces upstream errors", async () => { globalThis.fetch = async () => new Response("bad key", { status: 401, statusText: "Unauthorized" }); - const upstreamFailure = (await getExecutor("command-code")).execute({ + const upstreamFailure = await (await getExecutor("command-code")).execute({ model: "gpt-5.4-mini", stream: false, credentials: { apiKey: "cc_test_key" }, @@ -350,7 +352,7 @@ test("Command Code stream preserves the upstream OpenAI usage chunk (passthrough globalThis.fetch = async () => new Response(sse, { status: 200, headers: { "Content-Type": "text/event-stream" } }); - const { response } = (await getExecutor("command-code")).execute({ + const { response } = await (await getExecutor("command-code")).execute({ model: "gpt-5.4-mini", stream: true, credentials: { apiKey: "cc_test_key" }, @@ -364,4 +366,131 @@ test("Command Code stream preserves the upstream OpenAI usage chunk (passthrough assert.ok(text.includes('"cached_tokens":4')); assert.ok(text.includes('"reasoning_tokens":1')); assert.ok(text.includes("data: [DONE]")); -}); \ No newline at end of file +}); + +test("Command Code executor falls back to /alpha/generate on 403 (e.g. Go plan without Provider API access) for streaming", async () => { + const calls: Array<{ + url: string; + headers: Record; + body: Record; + }> = []; + globalThis.fetch = async (url, init = {}) => { + const urlStr = String(url); + calls.push({ + url: urlStr, + headers: (init.headers || {}) as Record, + body: JSON.parse(String(init.body)) as Record, + }); + + if (urlStr.includes("/provider/v1/chat/completions")) { + return new Response( + JSON.stringify({ + error: { + message: + "Your Go plan doesn't include API access. Upgrade to Provider or higher at https://commandcode.ai/billing to use these endpoints.", + type: "permission_error", + code: "upgrade_required", + }, + }), + { status: 403, headers: { "Content-Type": "application/json" } } + ); + } + + if (urlStr.includes("/alpha/generate")) { + const cliSse = + 'data: {"type":"text-delta","text":"Hello from CLI fallback"}\n\n' + + 'data: {"type":"finish","finishReason":"stop","usage":{"inputTokens":5,"outputTokens":4,"totalTokens":9}}\n\n'; + return new Response(cliSse, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } + + return new Response("Not found", { status: 404 }); + }; + + const { response, url, headers } = await (await getExecutor("command-code")).execute({ + model: "deepseek/deepseek-v4-flash", + stream: true, + credentials: { apiKey: "cc_go_plan_key" }, + body: { messages: [{ role: "user", content: "Hi" }] }, + }); + + assert.equal(calls.length, 2, "probed /provider/v1 first, then fell back to /alpha/generate"); + assert.ok(calls[0].url.includes("/provider/v1/chat/completions")); + assert.ok(calls[1].url.includes("/alpha/generate")); + assert.equal(calls[1].headers["x-cli-environment"], "external"); + assert.equal(calls[1].headers["x-command-code-version"], "1.15.1"); + assert.equal((calls[1].body.config as Record).environment, "external"); + + assert.ok(url.includes("/alpha/generate")); + assert.equal(headers["x-cli-environment"], "external"); + const text = await response.text(); + assert.ok(text.includes("Hello from CLI fallback")); + assert.ok(text.includes("data: [DONE]")); +}); + +test("Command Code executor falls back to /alpha/generate on 403 (Go plan) for non-stream JSON", async () => { + const calls: string[] = []; + globalThis.fetch = async (url) => { + const urlStr = String(url); + calls.push(urlStr); + + if (urlStr.includes("/provider/v1/chat/completions")) { + return new Response( + JSON.stringify({ error: { message: "upgrade_required", code: "upgrade_required" } }), + { status: 403, headers: { "Content-Type": "application/json" } } + ); + } + + if (urlStr.includes("/alpha/generate")) { + const cliSse = + 'data: {"type":"text-delta","text":"Non-stream answer"}\n\n' + + 'data: {"type":"finish","finishReason":"stop","usage":{"inputTokens":3,"outputTokens":2,"totalTokens":5}}\n\n'; + return new Response(cliSse, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } + + return new Response("Not found", { status: 404 }); + }; + + const { response } = await (await getExecutor("command-code")).execute({ + model: "gpt-5.4", + stream: false, + credentials: { apiKey: "cc_go_plan_key" }, + body: { messages: [{ role: "user", content: "Hi" }] }, + }); + + assert.equal(calls.length, 2); + const json = (await response.json()) as Record; + assert.equal(json.object, "chat.completion"); + const choices = json.choices as Array<{ message: { content: string } }>; + assert.equal(choices[0].message.content, "Non-stream answer"); + const usage = json.usage as { total_tokens: number }; + assert.equal(usage.total_tokens, 5); +}); + +test("Command Code executor surfaces fallback error when both /provider/v1 and /alpha/generate fail", async () => { + globalThis.fetch = async (url) => { + const urlStr = String(url); + if (urlStr.includes("/provider/v1/chat/completions")) { + return new Response("forbidden", { status: 403 }); + } + if (urlStr.includes("/alpha/generate")) { + return new Response("insufficient credits on fallback", { status: 400 }); + } + return new Response("error", { status: 500 }); + }; + + const result = await (await getExecutor("command-code")).execute({ + model: "gpt-5.4", + stream: false, + credentials: { apiKey: "cc_key" }, + body: { messages: [{ role: "user", content: "Hi" }] }, + }); + + assert.equal(result.response.status, 400); + assert.equal(await result.response.text(), "insufficient credits on fallback"); +}); diff --git a/tests/unit/provider-validation-specialty.test.ts b/tests/unit/provider-validation-specialty.test.ts index 46fab23dd898..6df3823e8d3d 100644 --- a/tests/unit/provider-validation-specialty.test.ts +++ b/tests/unit/provider-validation-specialty.test.ts @@ -2369,6 +2369,52 @@ test("validateCommandCodeProvider rejects auth failures and provider outages", a }); }); +test("validateCommandCodeProvider falls back to /alpha/generate when /provider/v1 returns 403 (Go plan)", async () => { + const calls: Array<{ + url: string; + headers: Record; + body: Record; + }> = []; + globalThis.fetch = async (url, init = {}) => { + const urlStr = String(url); + calls.push({ + url: urlStr, + headers: (init.headers || {}) as Record, + body: JSON.parse(String(init.body)) as Record, + }); + + if (urlStr.includes("/provider/v1/chat/completions")) { + return new Response( + JSON.stringify({ + error: { + message: "Your Go plan doesn't include API access.", + type: "permission_error", + code: "upgrade_required", + }, + }), + { status: 403, headers: { "Content-Type": "application/json" } } + ); + } + + if (urlStr.includes("/alpha/generate")) { + return new Response("ok", { status: 200 }); + } + + return new Response("Not found", { status: 404 }); + }; + + const result = await validateCommandCodeProvider({ apiKey: "cc_go_plan_key" }); + + assert.equal(result.valid, true); + assert.equal(result.error, null); + assert.equal(calls.length, 2); + assert.ok(calls[0].url.includes("/provider/v1/chat/completions")); + assert.ok(calls[1].url.includes("/alpha/generate")); + assert.equal(calls[1].headers["x-cli-environment"], "external"); + assert.equal(calls[1].headers["x-command-code-version"], "1.15.1"); + assert.equal(calls[1].body.config.environment, "external"); +}); + // ─── claude-web validator ──────────────────────────────────────────────────── const { __setTlsFetchOverrideForTesting: __setClaudeTlsFetchOverride } =