mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 04:42:10 +03:00
* chore(release): open v3.8.39 development cycle * docs(changelog): backfill 5 v3.8.38 bullets merged after release finalize These PRs squash-merged into release/v3.8.38 between the CHANGELOG finalize (ff57be32f) and the merge-to-main (ae6e2342d), so they shipped in the v3.8.38 tag but had no bullet: - feat(compression): Ionizer engine (lossy JSON-array sampling + CCR) (#5148) - fix(sse): preserve non-stream reasoning fields (#5155, @rdself) - fix(i18n): add missing English UI labels (#5153, @rdself) - test(combo): gated live smoke (#5151) + release-expectations refresh (#5150, @KooshaPari) (#5129 exact-host Anthropic baseUrl is already covered by the #5130 bullet — same CodeQL #674.) Synced 41 i18n CHANGELOG mirrors. * feat(compression): TOON best-of-N candidate encoder + encoder A/B table (#5163) Integrated into release/v3.8.39. TOON best-of-N candidate encoder (GCF default, fail-open). 17/17 unit tests pass on merge result; CI reds were base-stale + Quality Ratchet DRIFT. * fix(zenmux): normalize vendor-prefixed GLM system roles (#5158) Integrated into release/v3.8.39. ZenMux vendor-prefixed GLM system-role normalization; 12/12 role-normalizer tests pass on merge result. CI reds base-stale. * [codex] fix xAI OAuth test and reasoning effort (#5157) Integrated into release/v3.8.39. xAI reasoning-effort normalization (max/xhigh→high) + OAuth test config; 46/46 xai-translator tests pass on merge result. CI reds base-stale. * docs(i18n): add Traditional Chinese (zh-TW) README and update zh-CN to latest (#5162) Integrated into release/v3.8.39. Traditional Chinese (zh-TW) README + zh-CN refresh; docs-only. * test(security): guard PII redaction stays opt-in (default off) + Hard Rule #20 (#5159) Integrated into release/v3.8.39. PII opt-in regression guard + Hard Rule #20; rebased to strip base-drift (+81/-1). 5/5 guard tests pass; flip-proof verified. * test(combo): deterministic context-relay universal-handoff coverage (closes phase-2 TODO) (#5168) Integrated into release/v3.8.39. Deterministic context-relay universal-handoff coverage (3 tests); 3/3 pass on merge result. * docs(i18n): full sync zh-TW and zh-CN README with canonical English v3.8.39 (#5171) Integrated into release/v3.8.39. Full zh-TW docs tree + zh-CN sync with canonical English v3.8.39; docs-only. * fix(serve): honour HOSTNAME from .env instead of hardcoding 0.0.0.0 (#5134) (#5170) Integrated into release/v3.8.39. HOSTNAME env override in serve (#5134) + regression test (4/4, TDD flip-proof verified). * fix(sse): resolve nameless deepseek-web tool blocks via parameter-schema match (#5154) (#5173) Integrated into release/v3.8.39. Schema-based nameless deepseek-web tool-block resolution (#5154); 6/6 tests pass on merge result (incl. ambiguous/no-match negatives + named-tag no-regression). * fix(sse): normalize array user content for Command Code to avoid upstream 400 (#5166) (#5174) Integrated into release/v3.8.39. Normalize array user content for Command Code (#5166, user-array/400 symptom); 4/4 tests pass on merge result. * fix(sse): defer </think> close so it never leaks before tool_calls (#5123) (#5175) Integrated into release/v3.8.39. Defer </think> close so it never leaks before tool_calls (#5123); 4/4 tests pass (incl. #4633 no-regression). CHANGELOG synced to keep all 3 v3.8.39 fixes. * fix(dashboard): use amber for home update-step warning icon (#5176) Integrated into release/v3.8.39. Amber for home update-step warning icon; 1/1 UI test. * fix(api): LAN/Tailscale dashboard — host-aware CSP + GET-exempt version route + combo field errors (#5083) (#5177) Integrated into release/v3.8.39. Host-aware CSP (ReDoS/injection-safe host validation) + GET-exempt /api/system/version (POST/spawn stays LOCAL_ONLY, exact-match safe-methods-only) + COMBO_002 firstField. 44/44 tests + route-guard membership gate green. CHANGELOG synced to keep all 4 v3.8.39 fixes. * fix(api): replace #5083 global middleware CSP with declarative ws: scheme (#5083) Follow-up to PR #5177 (merged): that version implemented the LAN-CSP fix (Bug 1) with a new global `src/middleware.ts` + `src/server/csp.ts`, which contradicts the project's documented architecture — 'No global Next.js middleware — interception is route-specific' (CLAUDE.md / AGENTS.md) — and was merged unverified (middleware vs next.config header precedence was never confirmed in a real build). This replaces that approach with the minimal, declarative equivalent: • next.config.mjs: connect-src now permits the bare `ws:` scheme (symmetric with the bare `wss:` already allowed) so the dashboard can reach its own Live WS server from a LAN/Tailscale host. No middleware. • Removes src/middleware.ts, src/server/csp.ts, and tests/unit/csp-host-aware.test.ts. • Adds tests/unit/csp-lan-ws-5083.test.ts (incl. a guard asserting src/middleware.ts does NOT exist, so the global-middleware approach cannot silently return). Bugs 2 (GET-exempt /api/system/version) and 3 (COMBO_002 field surfacing) from #5177 are unaffected and remain in place. Co-authored-by: KooshaPari <KooshaPari@users.noreply.github.com> * test(combo): end-to-end quota-share DRR routing-decision coverage (matrix parity) (#5179) Integrated into release/v3.8.39. Quota-share DRR routing-decision coverage (matrix parity); 2/2 pass on merge result. * feat(agent-bridge): graceful cert-install fallback with manual guide for containers (#4546) (#5178) Integrated into release/v3.8.39. Agent-bridge graceful cert-install fallback + manual guide (#4546); 6/6 tests pass on merge result. * fix(antigravity): family-scoped quota lockout (gemini/claude buckets) (#5180) Integrated into release/v3.8.39 — family-scoped antigravity quota lockout. Rebased from v3.8.37 + validated (vitest 5/5, typecheck clean, full combo-matrix green, model-lockout 99/0). Same-model cross-account retry (chat.ts) deferred pending live antigravity VPS validation. * fix(cli): force NODE_ENV to match dev/start run mode in custom Next server (#5189) Integrated into release/v3.8.39. Force NODE_ENV to match dev/start run mode in custom Next server; 2/2 source-scan+ordering tests pass on merge result. * feat(compression): CCR ranged/grep/stats retrieval (ReDoS-safe, backward-compat) (#5187) Integrated into release/v3.8.39. CCR ranged/grep/stats retrieval (safe-regex ReDoS guard + length/match caps); 17/17 tests pass on merge result. * docs(combo): sync all combo/routing-strategy docs to current state + document test coverage (#5185) Integrated into release/v3.8.39. Combo/routing-strategy docs sync; docs-only. * fix(mcp): return 404 (not 400) for unknown Streamable HTTP session id (#5169) (#5191) * fix(api): respect blocked Auto (Zero-Config) provider in /v1/models catalog (#5192) (#5194) * test(combo): deterministic context-relay codex quota-handoff coverage (closes last gap) (#5195) * test(ci): wire antigravity-quota-family under test:vitest (fix test-discovery orphan) (#5196) * fix(oauth): antigravity login no longer hangs — fire-and-forget onboarding + bounded post-exchange (#5193) Antigravity OAuth hang fix (no-PKCE/no-openid + bounded post-exchange + exchange-500 fix). Includes #5200 (Koosha) revert + owner rebaseline to keep documented comments. Integrated into release/v3.8.39. * feat(oauth): remote Antigravity login via local helper + paste-credentials (#5203) Remote Antigravity login: local helper (omniroute login antigravity) + paste-credentials. Integrated into release/v3.8.39. * fix(translator): accept Claude Messages shape in non-stream malformed-200 guard (#5156) Integrated into release/v3.8.39 * fix(cli): default dev bundler to Turbopack (16.2.x panic no longer reproduces) (#5206) Integrated into release/v3.8.39 * fix(cli): auto-calibrate server V8 heap from physical RAM (#5172) (#5213) The server was spawned with a fixed --max-old-space-size=512 (omniroute serve) or no heap flag at all (Electron), so RAM-rich boxes still OOM-crashed under load (Ineffective mark-compacts near heap limit ~500MB) with many providers/ accounts and large model catalogs. New calibrateHeapFallbackMb(os.totalmem()) defaults the heap to ~35% of RAM clamped [512,4096], wired into serve.mjs and electron/main.js. Explicit OMNIROUTE_MEMORY_MB still wins (#2939 unchanged). Also addresses #5160 (same OOM root); #5152 (docker) benefits via the same knob. Closes #5172 * fix(proxy): coalesce fast-fail health probes (#5208) Integrated into release/v3.8.39 * fix(proxy): close dispatchers when clearing cache (#5202) Integrated into release/v3.8.39 * fix(cli): raise dev server Node heap limit to 8GB to prevent OOM (#5198) Integrated into release/v3.8.39 * fix(auth): allow synthetic no-auth fallback for mimocode (#5205) Integrated into release/v3.8.39 * fix(oauth): preserve Antigravity refresh_token on empty/omitted upstream response (#3850) (#5214) Google's OAuth refresh tokens are non-rotating: the refresh response usually omits refresh_token and occasionally returns it as an empty string. The Antigravity executor used `typeof tokens.refresh_token === "string" ? ... ` which accepts "" (typeof "" === "string") and overwrote the stored token with empty, nulling it on first refresh. Now treats non-string OR empty as absent and preserves credentials.refreshToken, matching refreshGoogleToken semantics. Closes #3850 * fix(responses): normalize non-array input (#5204) Integrated into release/v3.8.39 * fix(stream): normalize safety finish reasons via shared helper (#5197) Integrated into release/v3.8.39 * fix(request-logger): never render negative '(-100%)' compression badge (#5201) Integrated into release/v3.8.39 * fix(combo): reject empty responses api output (#5207) Integrated into release/v3.8.39 — combo failover now rejects empty Responses API output (validateQuality). Baseline rebaseline dropped (main-measured drift; maintainer rebaselines at release). * fix(pwa): prefer cached navigation before offline page (#5209) Integrated into release/v3.8.39 — PWA service worker prefers cached navigation before offline page (#5165). * chore(release): v3.8.39 — 2026-06-28 * chore(release): rebaseline openapi+i18n coverage ratchet drift for v3.8.39 --------- Co-authored-by: Arthur Bodera <abodera@gmail.com> Co-authored-by: Nguyen Minh <lop123thcs@gmail.com> Co-authored-by: lunkerchen <labanchen@gmail.com> Co-authored-by: Ankit <177378174+anki1kr@users.noreply.github.com> Co-authored-by: KooshaPari <KooshaPari@users.noreply.github.com> Co-authored-by: Ardem2025 <ardemb22@gmail.com> Co-authored-by: backryun <bakryun0718@proton.me> Co-authored-by: Anton <39598727+NomenAK@users.noreply.github.com> Co-authored-by: KooshaPari <42529354+KooshaPari@users.noreply.github.com> Co-authored-by: Wilson <pedbookmed@gmail.com> Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
609 lines
19 KiB
TypeScript
609 lines
19 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
|
|
import { REGISTRY } from "../config/providerRegistry.ts";
|
|
import { BaseExecutor, mergeUpstreamExtraHeaders, type ExecuteInput } from "./base.ts";
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
export const COMMAND_CODE_VERSION = process.env.COMMAND_CODE_VERSION?.trim() || "0.33.2";
|
|
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;
|
|
}
|
|
|
|
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 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");
|
|
}
|
|
|
|
function convertTools(tools: unknown): unknown[] {
|
|
return asRecordArray(tools).map((tool) => {
|
|
const fn = isRecord(tool.function) ? tool.function : tool;
|
|
return {
|
|
type: "function",
|
|
name: stringValue(fn.name) || "",
|
|
description: stringValue(fn.description) || "",
|
|
input_schema: isRecord(fn.parameters) ? fn.parameters : {},
|
|
};
|
|
});
|
|
}
|
|
|
|
function completeToolCallIds(messages: JsonRecord[]): Set<string> {
|
|
const callIds = new Set<string>();
|
|
const resultIds = new Set<string>();
|
|
|
|
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);
|
|
}
|
|
} else if (message.role === "tool") {
|
|
const id = stringValue(message.tool_call_id);
|
|
if (id) resultIds.add(id);
|
|
}
|
|
}
|
|
|
|
return new Set([...callIds].filter((id) => resultIds.has(id)));
|
|
}
|
|
|
|
function convertMessages(messages: unknown): { system: string; messages: unknown[] } {
|
|
const source = asRecordArray(messages);
|
|
const pairedToolCallIds = completeToolCallIds(source);
|
|
const out: unknown[] = [];
|
|
const system: string[] = [];
|
|
|
|
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: normalizeContentText(message.content) });
|
|
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 : {};
|
|
parts.push({
|
|
type: "tool-call",
|
|
toolCallId: id,
|
|
toolName: stringValue(fn.name) || "",
|
|
input: recordOrEmpty(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;
|
|
out.push({
|
|
role: "tool",
|
|
content: [
|
|
{
|
|
type: "tool-result",
|
|
toolCallId,
|
|
toolName: stringValue(message.name) || "",
|
|
output: { type: "text", value: normalizeContentText(message.content) },
|
|
},
|
|
],
|
|
});
|
|
}
|
|
}
|
|
|
|
return { system: system.join("\n\n"), messages: out };
|
|
}
|
|
|
|
function clampMaxTokens(value: unknown, cap: number = MAX_COMMAND_CODE_TOKENS): number {
|
|
const numeric = numberValue(value) ?? cap;
|
|
return Math.max(1, Math.min(Math.floor(numeric), cap));
|
|
}
|
|
|
|
// Resolve the per-model max_tokens cap for a given CommandCode model id.
|
|
// Falls back to MAX_COMMAND_CODE_TOKENS when the model isn't registered or
|
|
// when the registry entry omits maxOutputTokens. Without this, GLM-5.x
|
|
// requests get capped at 200_000 and rejected with "限制数值范围[1,131072]".
|
|
function getModelMaxTokensCap(modelId: string): number {
|
|
const entry = REGISTRY["command-code"];
|
|
if (!entry) return MAX_COMMAND_CODE_TOKENS;
|
|
const model = entry.models?.find((m: { id: string }) => m.id === modelId);
|
|
const registryCap = (model as { maxOutputTokens?: number } | undefined)?.maxOutputTokens;
|
|
return typeof registryCap === "number" && registryCap > 0
|
|
? registryCap
|
|
: MAX_COMMAND_CODE_TOKENS;
|
|
}
|
|
|
|
// Reasoning/thinking fields that payload rules or clients may inject and that
|
|
// CommandCode's upstream accepts inside `params`. Without this pass-through,
|
|
// payload-rule overrides on these fields are silently dropped (#2986 follow-up).
|
|
const COMMAND_CODE_PASSTHROUGH_FIELDS = [
|
|
"reasoning_effort",
|
|
"reasoning",
|
|
"thinking",
|
|
"effort",
|
|
"output_config",
|
|
"extra_body",
|
|
] as const;
|
|
|
|
function buildCommandCodeBody(model: string, body: unknown, stream = false): JsonRecord {
|
|
const input = isRecord(body) ? body : {};
|
|
const converted = convertMessages(input.messages);
|
|
const explicitSystem = typeof input.system === "string" ? input.system : "";
|
|
const system = [converted.system, explicitSystem].filter(Boolean).join("\n\n");
|
|
|
|
// Payload rules may rewrite `body.model` (e.g. deepseek-v4-pro-max →
|
|
// deepseek/deepseek-v4-pro for the command-code provider). Prefer the
|
|
// rewritten value if present; fall back to the resolved combo model arg.
|
|
const resolvedModel =
|
|
typeof input.model === "string" && input.model.trim().length > 0 ? input.model : model;
|
|
|
|
const params: JsonRecord = {
|
|
model: resolvedModel,
|
|
messages: converted.messages,
|
|
tools: convertTools(input.tools),
|
|
system,
|
|
max_tokens: clampMaxTokens(
|
|
input.max_tokens ?? input.max_completion_tokens,
|
|
getModelMaxTokensCap(resolvedModel)
|
|
),
|
|
stream: true,
|
|
};
|
|
|
|
for (const field of COMMAND_CODE_PASSTHROUGH_FIELDS) {
|
|
const value = input[field];
|
|
if (value !== undefined && value !== null) {
|
|
params[field] = value;
|
|
}
|
|
}
|
|
|
|
return {
|
|
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,
|
|
};
|
|
}
|
|
|
|
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 applyEventToAggregate(event: JsonRecord, state: AggregateState): void {
|
|
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: stringValue(event.toolName) || stringValue(event.name) || "",
|
|
arguments: JSON.stringify(args),
|
|
},
|
|
});
|
|
break;
|
|
}
|
|
case "finish":
|
|
state.finishReason = mapFinishReason(event.finishReason);
|
|
state.usage = isRecord(event.totalUsage) ? event.totalUsage : null;
|
|
break;
|
|
}
|
|
}
|
|
|
|
function applyEventToAggregateOrThrow(event: JsonRecord, state: AggregateState): 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);
|
|
}
|
|
|
|
function usageFromCommandCode(usage: JsonRecord | null) {
|
|
if (!usage) return undefined;
|
|
const details = isRecord(usage.inputTokenDetails) ? usage.inputTokenDetails : {};
|
|
const prompt =
|
|
(numberValue(usage.inputTokens) || 0) + (numberValue(details.cacheReadTokens) || 0);
|
|
const completion = numberValue(usage.outputTokens) || 0;
|
|
return {
|
|
prompt_tokens: prompt,
|
|
completion_tokens: completion,
|
|
total_tokens: prompt + completion,
|
|
};
|
|
}
|
|
|
|
function createStreamResponse(
|
|
upstream: Response,
|
|
model: string,
|
|
signal?: AbortSignal | null
|
|
): Response {
|
|
const id = `chatcmpl-${randomUUID()}`;
|
|
const reader = upstream.body?.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
let sentRole = false;
|
|
let closed = false;
|
|
const state: AggregateState = {
|
|
content: "",
|
|
reasoning: "",
|
|
toolCalls: [],
|
|
finishReason: "stop",
|
|
usage: null,
|
|
};
|
|
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
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;
|
|
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) 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: stringValue(event.toolName) || stringValue(event.name) || "",
|
|
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": {
|
|
state.finishReason = mapFinishReason(event.finishReason);
|
|
state.usage = isRecord(event.totalUsage) ? event.totalUsage : null;
|
|
controller.enqueue(sse(chatCompletionChunk(id, model, {}, state.finishReason)));
|
|
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" })));
|
|
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
|
|
): Promise<Response> {
|
|
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);
|
|
}
|
|
}
|
|
if (buffer.trim()) {
|
|
const event = parseStreamLine(buffer);
|
|
if (isRecord(event)) applyEventToAggregateOrThrow(event, state);
|
|
}
|
|
} 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.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" },
|
|
});
|
|
}
|
|
|
|
export class CommandCodeExecutor extends BaseExecutor {
|
|
constructor(provider = "command-code") {
|
|
super(provider, REGISTRY["command-code"]);
|
|
}
|
|
|
|
buildUrl() {
|
|
const baseUrl = (this.config.baseUrl || "https://api.commandcode.ai").replace(/\/$/, "");
|
|
return `${baseUrl}${this.config.chatPath || "/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 headers: Record<string, string> = {
|
|
"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(headers, upstreamExtraHeaders);
|
|
|
|
const transformedBody = buildCommandCodeBody(model, body, stream);
|
|
const url = this.buildUrl();
|
|
const upstream = await fetch(url, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(transformedBody),
|
|
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,
|
|
};
|
|
}
|
|
|
|
const response = stream
|
|
? createStreamResponse(upstream, model, signal)
|
|
: await createJsonResponse(upstream, model, signal);
|
|
|
|
return { response, url, headers, transformedBody };
|
|
}
|
|
}
|