mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-16 12:12:23 +03:00
feat(providers): add local ZCode ACP backend (#10184)
* feat(providers): add local ZCode ACP backend * test(snapshots): regenerate translate-path golden for zcode provider The new local ZCode ACP backend (zcode://app-server/stdio) was added to the provider catalog but the translate-path golden snapshot was not regenerated, so the combined suite (provider-translate-path-golden.test.ts) failed on the merged tip. Regenerate the snapshot to include the zcode translate-path entry. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * docs(env): document ZCODE_* vars for the local zcode provider Registers the 11 ZCODE_* env vars read by the zcode executor (.env.example + docs/reference/ENVIRONMENT.md) so the env-doc-sync gate stays green. Co-authored-by: Diego Souza <8016841+diegosouzapw@users.noreply.github.com> * test(autoCombo): include zcode in the glm-family provider set #10184's local zcode backend advertises the full GLM_SHARED_MODELS line-up (registry/zcode, authType none) — same documented case as auggie and devin-cli-agentic. Update auto/glm provider-set assertion to include it. Co-authored-by: Diego Souza <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: roomhacker <roomhacker@bezrabotnyi.com> Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
15
.env.example
15
.env.example
@@ -735,6 +735,21 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# CLI_AUGGIE_BIN=auggie
|
||||
# AUGGIE_BIN=auggie
|
||||
|
||||
# ── ZCode (Z.ai GLM coding-plan CLI) local provider ──
|
||||
# The local "zcode" provider talks to the authenticated ZCode app-server over a
|
||||
# custom framed stdio protocol. Overrides below tune that stdio lifecycle.
|
||||
# ZCODE_BIN=zcode
|
||||
# ZCODE_ARGS=["--some-flag"]
|
||||
# ZCODE_CWD=
|
||||
# ZCODE_PROVIDER_ID=builtin:zai-coding-plan
|
||||
# ZCODE_SERVER_RUNTIME_ROOT=~/.zcode/server
|
||||
# ZCODE_SERVER_NODE=~/.zcode/server/node
|
||||
# ZCODE_SERVER_ENTRY=~/.zcode/server/zcode-server.cjs
|
||||
# ZCODE_STARTUP_TIMEOUT_MS=10000
|
||||
# ZCODE_RPC_TIMEOUT_MS=30000
|
||||
# ZCODE_TURN_TIMEOUT_MS=120000
|
||||
# ZCODE_POLL_INTERVAL_MS=250
|
||||
|
||||
# Override the Hermes Agent home directory (where OmniRoute reads/writes the
|
||||
# Hermes CLI config). Matches the env var the Hermes PowerShell installer sets
|
||||
# on Windows (%LOCALAPPDATA%\hermes); defaults to ~/.hermes when unset.
|
||||
|
||||
@@ -400,6 +400,17 @@ Controls how OmniRoute discovers and launches CLI sidecars (Claude Code, Codex,
|
||||
| `DEVIN_BRIDGE_SUBAGENT_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used for Claude Code subagents. |
|
||||
| `AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Absolute-path override for the Augment (Auggie) CLI binary used by the local `auggie` provider. Falls back to `CLI_AUGGIE_BIN`, then a PATH lookup. |
|
||||
| `CLI_AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Alias override for the Augment (Auggie) CLI binary path (checked after `AUGGIE_BIN`). |
|
||||
| `ZCODE_BIN` | `zcode` | `open-sse/executors/zcode.ts` | Binary used for the local `zcode` provider's stdio client. Falls back to `zcode` on PATH. |
|
||||
| `ZCODE_ARGS` | — | `open-sse/executors/zcode.ts` | JSON array (≤16 strings) of extra arguments passed to the `zcode` binary when launched via `cliTools`. |
|
||||
| `ZCODE_CWD` | `process.cwd()` | `open-sse/executors/zcode.ts` | Working directory for the ZCode app-server subprocess. |
|
||||
| `ZCODE_PROVIDER_ID` | `builtin:zai-coding-plan` | `open-sse/executors/zcode.ts` | Override for the provider id sent to the app-server. |
|
||||
| `ZCODE_SERVER_RUNTIME_ROOT` | `~/.zcode/server` | `open-sse/executors/zcode.ts` | Root of the ZCode app-server runtime (where the bundled `node` and `zcode-server.cjs` live). |
|
||||
| `ZCODE_SERVER_NODE` | `<runtimeRoot>/node` | `open-sse/executors/zcode.ts` | Node executable used to host the ZCode app-server. |
|
||||
| `ZCODE_SERVER_ENTRY` | `<runtimeRoot>/zcode-server.cjs` | `open-sse/executors/zcode.ts` | App-server entry script used to host the ZCode server. |
|
||||
| `ZCODE_STARTUP_TIMEOUT_MS` | `10000` | `open-sse/executors/zcode.ts` | Startup timeout (ms) before a ZCode app-server launch is considered failed. |
|
||||
| `ZCODE_RPC_TIMEOUT_MS` | `30000` | `open-sse/executors/zcode.ts` | Per-request RPC timeout (ms) for a ZCode app-server call. |
|
||||
| `ZCODE_TURN_TIMEOUT_MS` | `120000` | `open-sse/executors/zcode.ts` | Maximum duration (ms) of one ZCode turn before the supervisor times it out. |
|
||||
| `ZCODE_POLL_INTERVAL_MS` | `250` | `open-sse/executors/zcode.ts` | Polling interval (ms) for ZCode turn completion. |
|
||||
| `HERMES_HOME` | `~/.hermes` | `src/lib/cli-helper/config-generator/hermesHome.ts` | Hermes Agent home directory where OmniRoute reads/writes the Hermes CLI config. Matches the env var the Hermes PowerShell installer sets on Windows (`%LOCALAPPDATA%\hermes`). |
|
||||
|
||||
### CLI Profile Auto-Sync
|
||||
|
||||
@@ -154,6 +154,7 @@ import { command_codeProvider } from "./registry/command-code/index.ts";
|
||||
import { novitaProvider } from "./registry/novita/index.ts";
|
||||
import { regoloProvider } from "./registry/regolo/index.ts";
|
||||
import { devin_desktopProvider } from "./registry/devin-desktop/index.ts";
|
||||
import { zcodeProvider } from "./registry/zcode/index.ts";
|
||||
import { zed_hostedProvider } from "./registry/zed-hosted/index.ts";
|
||||
import { nanogptProvider } from "./registry/nanogpt/index.ts";
|
||||
import { scalewayProvider } from "./registry/scaleway/index.ts";
|
||||
@@ -412,6 +413,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
novita: novitaProvider,
|
||||
regolo: regoloProvider,
|
||||
"devin-desktop": devin_desktopProvider,
|
||||
zcode: zcodeProvider,
|
||||
"zed-hosted": zed_hostedProvider,
|
||||
nanogpt: nanogptProvider,
|
||||
scaleway: scalewayProvider,
|
||||
|
||||
18
open-sse/config/providers/registry/zcode/index.ts
Normal file
18
open-sse/config/providers/registry/zcode/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { GLM_SHARED_MODELS } from "../../../glmProvider.ts";
|
||||
|
||||
/**
|
||||
* Local ZCode app-server backend. Authentication remains in the user's local
|
||||
* ZCode profile (`builtin:zai-coding-plan`); OmniRoute does not receive or
|
||||
* persist the Z.ai credential.
|
||||
*/
|
||||
export const zcodeProvider: RegistryEntry = {
|
||||
id: "zcode",
|
||||
alias: "zc",
|
||||
format: "openai",
|
||||
executor: "zcode",
|
||||
baseUrl: "zcode://app-server/stdio",
|
||||
authType: "none",
|
||||
authHeader: "none",
|
||||
models: [...GLM_SHARED_MODELS],
|
||||
};
|
||||
@@ -33,6 +33,7 @@ import { NlpCloudExecutor } from "./nlpcloud.ts";
|
||||
import { DevinDesktopExecutor } from "./devin-desktop.ts";
|
||||
import { ZedHostedExecutor } from "./zed-hosted.ts";
|
||||
import { DevinCliExecutor } from "./devin-cli.ts";
|
||||
import { ZcodeExecutor } from "./zcode.ts";
|
||||
import { DevinCliAgenticExecutor } from "./devin-cli-agentic.ts";
|
||||
import { AuggieExecutor } from "./auggie.ts";
|
||||
import { DeepSeekWebExecutor } from "./deepseek-web.ts";
|
||||
@@ -134,6 +135,8 @@ const executors = {
|
||||
"devin-desktop": new DevinDesktopExecutor(),
|
||||
"zed-hosted": new ZedHostedExecutor(),
|
||||
"devin-cli": new DevinCliExecutor(),
|
||||
zcode: new ZcodeExecutor(),
|
||||
zc: new ZcodeExecutor(), // Alias
|
||||
"devin-cli-agentic": new DevinCliAgenticExecutor(),
|
||||
devin: new DevinCliExecutor(), // Alias
|
||||
"deepseek-web": new DeepSeekWebWithAutoRefreshExecutor(),
|
||||
|
||||
375
open-sse/executors/zcode.ts
Normal file
375
open-sse/executors/zcode.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { GLM_SHARED_MODELS } from "../config/glmProvider.ts";
|
||||
import { BaseExecutor, type ExecuteInput, type ExecutorExecuteResult, type ProviderCredentials } from "./base.ts";
|
||||
import { ZcodeAppServerClient, type ZcodeClientLike } from "./zcodeProtocol.ts";
|
||||
import { buildErrorBody, errorResponse, sanitizeErrorMessage } from "../utils/error.ts";
|
||||
|
||||
const ZCODE_URL = "zcode://app-server/stdio";
|
||||
const DEFAULT_PROVIDER_ID = "builtin:zai-coding-plan";
|
||||
const DEFAULT_TURN_TIMEOUT_MS = 120_000;
|
||||
const DEFAULT_POLL_INTERVAL_MS = 250;
|
||||
const TERMINAL_STATUSES = new Set(["completed", "idle", "paused", "error"]);
|
||||
const ZCODE_MODEL_ALLOWLIST = new Set(GLM_SHARED_MODELS.map((model) => model.id));
|
||||
const DEFAULT_ZCODE_MODEL = GLM_SHARED_MODELS[0]?.id || "glm-5.2";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type OpenAIMsg = { role?: string; content?: unknown };
|
||||
|
||||
type ZcodeCommand = { command: string; args: string[] };
|
||||
type ZcodeModelResolution = { ok: true; model: string } | { ok: false; error: string };
|
||||
|
||||
export interface ZcodeExecutorOptions {
|
||||
command?: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
providerId?: string;
|
||||
startupTimeoutMs?: number;
|
||||
requestTimeoutMs?: number;
|
||||
turnTimeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
clientFactory?: () => ZcodeClientLike;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as JsonRecord : {};
|
||||
}
|
||||
|
||||
function textFromContent(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.map((part) => {
|
||||
if (typeof part === "string") return part;
|
||||
const record = asRecord(part);
|
||||
if (record.type === "text" || record.type === "input_text" || record.type === "output_text") {
|
||||
return typeof record.text === "string" ? record.text : "";
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Convert an OpenAI conversation into one explicit ZCode coding turn. */
|
||||
export function buildZcodePrompt(messages: OpenAIMsg[]): string {
|
||||
const parts: string[] = [];
|
||||
for (const message of messages) {
|
||||
const text = textFromContent(message.content).trim();
|
||||
if (!text) continue;
|
||||
const role = String(message.role || "user");
|
||||
const label = role === "system" ? "System" : role === "assistant" ? "Assistant" : "User";
|
||||
parts.push(`[${label}]\n${text}`);
|
||||
}
|
||||
return parts.join("\n\n") || "(empty)";
|
||||
}
|
||||
|
||||
export function resolveZcodeModel(model: unknown): ZcodeModelResolution {
|
||||
const requested = typeof model === "string" ? model.trim() : "";
|
||||
if (!requested) return { ok: true, model: DEFAULT_ZCODE_MODEL };
|
||||
if (requested.startsWith("-")) {
|
||||
return { ok: false, error: `Invalid ZCode model \"${requested}\": model must not start with \"-\".` };
|
||||
}
|
||||
const normalized = requested.startsWith("zcode/")
|
||||
? requested.slice("zcode/".length)
|
||||
: requested;
|
||||
if (!ZCODE_MODEL_ALLOWLIST.has(normalized)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Unknown ZCode model \"${requested}\". Supported models: ${[...ZCODE_MODEL_ALLOWLIST].join(", ")}.`,
|
||||
};
|
||||
}
|
||||
return { ok: true, model: normalized };
|
||||
}
|
||||
|
||||
function parseArgs(raw: string | undefined): string[] {
|
||||
if (!raw) return ["app-server"];
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed) || parsed.length > 16 || !parsed.every((arg) => typeof arg === "string" && arg.length <= 4096)) {
|
||||
throw new Error("ZCODE_ARGS must be a JSON array of at most 16 strings");
|
||||
}
|
||||
return parsed as string[];
|
||||
}
|
||||
|
||||
function defaultCommand(): ZcodeCommand {
|
||||
const runtimeRoot = process.env.ZCODE_SERVER_RUNTIME_ROOT || join(homedir(), ".zcode", "server");
|
||||
const serverNode = process.env.ZCODE_SERVER_NODE || join(runtimeRoot, "node");
|
||||
const serverEntry = process.env.ZCODE_SERVER_ENTRY || join(runtimeRoot, "zcode-server.cjs");
|
||||
if (existsSync(serverNode) && existsSync(serverEntry)) {
|
||||
return { command: serverNode, args: [serverEntry] };
|
||||
}
|
||||
return { command: process.env.ZCODE_BIN || "zcode", args: parseArgs(process.env.ZCODE_ARGS) };
|
||||
}
|
||||
|
||||
function extractSessionId(value: unknown): string | undefined {
|
||||
const root = asRecord(value);
|
||||
const nested = asRecord(root.session);
|
||||
const sessionId = nested.sessionId ?? root.sessionId;
|
||||
return typeof sessionId === "string" && sessionId.trim() ? sessionId : undefined;
|
||||
}
|
||||
|
||||
function extractStatus(value: unknown): string | undefined {
|
||||
const root = asRecord(value);
|
||||
const nested = asRecord(root.session);
|
||||
const status = nested.status ?? root.status;
|
||||
return typeof status === "string" ? status : undefined;
|
||||
}
|
||||
|
||||
function extractTextFromMessage(value: unknown): { role?: string; text: string } {
|
||||
const message = asRecord(value);
|
||||
const info = asRecord(message.info);
|
||||
const role = typeof info.role === "string" ? info.role : typeof message.role === "string" ? message.role : undefined;
|
||||
const parts = Array.isArray(message.parts) ? message.parts : [];
|
||||
const text = parts
|
||||
.map((part) => {
|
||||
const record = asRecord(part);
|
||||
if (record.type === "text" && typeof record.text === "string") return record.text;
|
||||
return "";
|
||||
})
|
||||
.join("");
|
||||
return { role, text };
|
||||
}
|
||||
|
||||
function extractAssistantText(value: unknown): string {
|
||||
const root = asRecord(value);
|
||||
const messages = Array.isArray(root.messages) ? root.messages : [];
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const message = extractTextFromMessage(messages[i]);
|
||||
if (message.text && (!message.role || message.role === "assistant")) return message.text;
|
||||
}
|
||||
const nestedMessage = extractTextFromMessage(root.message);
|
||||
if (nestedMessage.text) return nestedMessage.text;
|
||||
for (const candidate of [root.content, root.text, root.output_text]) {
|
||||
if (typeof candidate === "string" && candidate.trim()) return candidate;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function extractErrorMessage(value: unknown): string {
|
||||
const root = asRecord(value);
|
||||
const nested = asRecord(root.error);
|
||||
for (const candidate of [nested.message, root.message, root.reason]) {
|
||||
if (typeof candidate === "string" && candidate.trim()) return candidate;
|
||||
}
|
||||
return "ZCode app-server returned an error";
|
||||
}
|
||||
|
||||
function makeWorkspace(cwd: string): JsonRecord {
|
||||
return { workspacePath: cwd, workspaceIdentity: cwd };
|
||||
}
|
||||
|
||||
function abortError(): Error {
|
||||
return new Error("ZCode request aborted");
|
||||
}
|
||||
|
||||
async function raceAbort<T>(promise: Promise<T>, signal?: AbortSignal | null): Promise<T> {
|
||||
if (!signal) return promise;
|
||||
if (signal.aborted) {
|
||||
promise.catch(() => undefined);
|
||||
throw abortError();
|
||||
}
|
||||
let onAbort: (() => void) | undefined;
|
||||
const aborted = new Promise<T>((_, reject) => {
|
||||
onAbort = () => reject(abortError());
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
promise.catch(() => undefined);
|
||||
try {
|
||||
return await Promise.race([promise, aborted]);
|
||||
} finally {
|
||||
if (onAbort) signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
async function delay(ms: number, signal?: AbortSignal | null): Promise<void> {
|
||||
if (ms <= 0) {
|
||||
if (signal?.aborted) throw abortError();
|
||||
return;
|
||||
}
|
||||
await raceAbort(new Promise<void>((resolveDelay) => {
|
||||
const timer = setTimeout(resolveDelay, ms);
|
||||
timer.unref?.();
|
||||
}), signal);
|
||||
}
|
||||
|
||||
function estimateTokens(text: string): number {
|
||||
return Math.max(1, Math.ceil(text.length / 4));
|
||||
}
|
||||
|
||||
function completionResponse(model: string, prompt: string, content: string): Response {
|
||||
const promptTokens = estimateTokens(prompt);
|
||||
const completionTokens = estimateTokens(content);
|
||||
return new Response(JSON.stringify({
|
||||
id: `chatcmpl-zcode-${Date.now()}`,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model,
|
||||
choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
|
||||
usage: {
|
||||
prompt_tokens: promptTokens,
|
||||
completion_tokens: completionTokens,
|
||||
total_tokens: promptTokens + completionTokens,
|
||||
estimated: true,
|
||||
},
|
||||
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
function sseResponse(model: string, content: string): Response {
|
||||
const id = `chatcmpl-zcode-${Date.now()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
const chunks = [
|
||||
{ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }] },
|
||||
{ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { content }, finish_reason: null }] },
|
||||
{ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] },
|
||||
];
|
||||
const body = `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`;
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" },
|
||||
});
|
||||
}
|
||||
|
||||
function sseErrorResponse(status: number, message: string): Response {
|
||||
const body = `data: ${JSON.stringify(buildErrorBody(status, message))}\n\ndata: [DONE]\n\n`;
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" },
|
||||
});
|
||||
}
|
||||
|
||||
export class ZcodeExecutor extends BaseExecutor {
|
||||
private readonly options: ZcodeExecutorOptions;
|
||||
|
||||
constructor(options: ZcodeExecutorOptions = {}) {
|
||||
super("zcode", { id: "zcode", baseUrl: ZCODE_URL, format: "openai" });
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
buildUrl(): string {
|
||||
return ZCODE_URL;
|
||||
}
|
||||
|
||||
transformRequest(): null {
|
||||
return null;
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput): Promise<ExecutorExecuteResult> {
|
||||
const resolution = resolveZcodeModel(input.model);
|
||||
if (!resolution.ok) {
|
||||
const message = "error" in resolution ? resolution.error : "Invalid ZCode model";
|
||||
return input.stream ? sseErrorResponse(400, message) : errorResponse(400, message);
|
||||
}
|
||||
|
||||
const body = asRecord(input.body);
|
||||
const messages = Array.isArray(body.messages) ? body.messages as OpenAIMsg[] : [];
|
||||
const prompt = buildZcodePrompt(messages);
|
||||
input.log?.info?.("ZCODE", `local app-server turn started model=${resolution.model}`);
|
||||
|
||||
try {
|
||||
const content = await this.runTurn(resolution.model, prompt, input.signal, input.log);
|
||||
const response = input.stream
|
||||
? sseResponse(resolution.model, content)
|
||||
: completionResponse(resolution.model, prompt, content);
|
||||
return {
|
||||
response,
|
||||
url: ZCODE_URL,
|
||||
headers: {},
|
||||
transformedBody: { model: resolution.model, promptLength: prompt.length, buffered: true },
|
||||
transport: "local-zcode-app-server",
|
||||
};
|
||||
} catch (error) {
|
||||
const message = sanitizeErrorMessage(error instanceof Error ? error.message : String(error));
|
||||
input.log?.warn?.("ZCODE", message);
|
||||
return input.stream ? sseErrorResponse(502, message) : errorResponse(502, message);
|
||||
}
|
||||
}
|
||||
|
||||
private createClient(): ZcodeClientLike {
|
||||
if (this.options.clientFactory) return this.options.clientFactory();
|
||||
const command = this.options.command || process.env.ZCODE_SERVER_NODE || defaultCommand().command;
|
||||
const args = this.options.args || (process.env.ZCODE_SERVER_NODE
|
||||
? [process.env.ZCODE_SERVER_ENTRY || join(process.env.ZCODE_SERVER_RUNTIME_ROOT || join(homedir(), ".zcode", "server"), "zcode-server.cjs")]
|
||||
: defaultCommand().args);
|
||||
return new ZcodeAppServerClient({
|
||||
command,
|
||||
args,
|
||||
cwd: this.options.cwd || process.env.ZCODE_CWD || process.cwd(),
|
||||
startupTimeoutMs: this.options.startupTimeoutMs ?? Number(process.env.ZCODE_STARTUP_TIMEOUT_MS || 10_000),
|
||||
requestTimeoutMs: this.options.requestTimeoutMs ?? Number(process.env.ZCODE_RPC_TIMEOUT_MS || 30_000),
|
||||
});
|
||||
}
|
||||
|
||||
private async runTurn(
|
||||
model: string,
|
||||
prompt: string,
|
||||
signal: AbortSignal | null | undefined,
|
||||
log: ExecuteInput["log"]
|
||||
): Promise<string> {
|
||||
const client = this.createClient();
|
||||
const cwd = resolve(this.options.cwd || process.env.ZCODE_CWD || process.cwd());
|
||||
const workspace = makeWorkspace(cwd);
|
||||
const providerId = this.options.providerId || process.env.ZCODE_PROVIDER_ID || DEFAULT_PROVIDER_ID;
|
||||
const turnTimeoutMs = this.options.turnTimeoutMs ?? Number(process.env.ZCODE_TURN_TIMEOUT_MS || DEFAULT_TURN_TIMEOUT_MS);
|
||||
const pollIntervalMs = this.options.pollIntervalMs ?? Number(process.env.ZCODE_POLL_INTERVAL_MS || DEFAULT_POLL_INTERVAL_MS);
|
||||
let sessionId: string | undefined;
|
||||
|
||||
try {
|
||||
await raceAbort(client.start(), signal);
|
||||
const initialized = asRecord(await raceAbort(client.call("zcode-agent", "initialize", [workspace]), signal));
|
||||
if (initialized.available !== true) {
|
||||
throw new Error(extractErrorMessage(initialized));
|
||||
}
|
||||
|
||||
const created = await raceAbort(client.call("zcode-agent", "createSession", [{
|
||||
...workspace,
|
||||
sessionTraceId: randomUUID(),
|
||||
mode: "build",
|
||||
persistence: "persistent",
|
||||
}]), signal);
|
||||
sessionId = extractSessionId(created);
|
||||
if (!sessionId) throw new Error("ZCode createSession returned no sessionId");
|
||||
|
||||
await raceAbort(client.call("zcode-agent", "setModel", [{
|
||||
...workspace,
|
||||
sessionId,
|
||||
model: { providerId, modelId: model },
|
||||
}]), signal);
|
||||
|
||||
let state: unknown = await raceAbort(client.call("zcode-agent", "sendPrompt", [{
|
||||
...workspace,
|
||||
sessionId,
|
||||
inputId: randomUUID(),
|
||||
content: prompt,
|
||||
}]), signal);
|
||||
const deadline = Date.now() + Math.max(1, turnTimeoutMs);
|
||||
|
||||
while (Date.now() <= deadline) {
|
||||
if (signal?.aborted) throw abortError();
|
||||
const text = extractAssistantText(state);
|
||||
const status = extractStatus(state);
|
||||
if (text && (status === undefined || TERMINAL_STATUSES.has(status))) return text;
|
||||
if (status === "error") throw new Error(extractErrorMessage(state));
|
||||
await delay(Math.max(0, pollIntervalMs), signal);
|
||||
state = await raceAbort(client.call("zcode-agent", "readSession", [{
|
||||
...workspace,
|
||||
sessionId,
|
||||
messageLimit: 200,
|
||||
}]), signal);
|
||||
}
|
||||
const finalText = extractAssistantText(state);
|
||||
if (finalText) return finalText;
|
||||
throw new Error("ZCode turn timed out before an assistant response was available");
|
||||
} finally {
|
||||
if (sessionId && !signal?.aborted) {
|
||||
await client.call("zcode-agent", "closeSession", [{ ...workspace, sessionId }]).catch(() => undefined);
|
||||
}
|
||||
await client.close().catch((error) => log?.debug?.("ZCODE", `app-server close failed: ${sanitizeErrorMessage(error)}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Credentials are intentionally ignored: the local ZCode profile owns auth.
|
||||
override buildHeaders(_credentials: ProviderCredentials): Record<string, string> {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
438
open-sse/executors/zcodeProtocol.ts
Normal file
438
open-sse/executors/zcodeProtocol.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
|
||||
const HEADER_SIZE = 13;
|
||||
const REGULAR_MESSAGE = 1;
|
||||
const INITIALIZE_MESSAGE = 200;
|
||||
const RESPONSE_MESSAGE = 201;
|
||||
const ERROR_MESSAGE = 202;
|
||||
const CANCELED_MESSAGE = 203;
|
||||
const MAX_FRAME_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export interface ZcodeAppServerClientOptions {
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
startupTimeoutMs?: number;
|
||||
requestTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface ZcodeClientLike {
|
||||
start(): Promise<void>;
|
||||
call(channel: string, method: string, args: unknown[]): Promise<unknown>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
interface DecodedValue {
|
||||
value: unknown;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
function encodeVql(value: number): Buffer {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new Error(`ZCode protocol requires a non-negative integer, got ${String(value)}`);
|
||||
}
|
||||
const bytes: number[] = [];
|
||||
let remaining = value;
|
||||
do {
|
||||
let next = remaining % 128;
|
||||
remaining = Math.floor(remaining / 128);
|
||||
if (remaining > 0) next |= 0x80;
|
||||
bytes.push(next);
|
||||
} while (remaining > 0);
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
function decodeVql(data: Uint8Array, offset: number): { value: number; offset: number } {
|
||||
let value = 0;
|
||||
let multiplier = 1;
|
||||
let cursor = offset;
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
if (cursor >= data.byteLength) throw new Error("Truncated ZCode variable-length quantity");
|
||||
const next = data[cursor++];
|
||||
value += (next & 0x7f) * multiplier;
|
||||
if ((next & 0x80) === 0) return { value, offset: cursor };
|
||||
multiplier *= 128;
|
||||
}
|
||||
throw new Error("Invalid ZCode variable-length quantity");
|
||||
}
|
||||
|
||||
/** Serialize one value using ZCode's SocketProtocol value encoding. */
|
||||
export function encodeZcodeValue(value: unknown): Buffer {
|
||||
if (value === undefined) return Buffer.from([0]);
|
||||
if (typeof value === "string") {
|
||||
const bytes = Buffer.from(value, "utf8");
|
||||
return Buffer.concat([Buffer.from([1]), encodeVql(bytes.byteLength), bytes]);
|
||||
}
|
||||
if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
|
||||
const bytes = Buffer.from(value);
|
||||
return Buffer.concat([Buffer.from([2]), encodeVql(bytes.byteLength), bytes]);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return Buffer.concat([
|
||||
Buffer.from([4]),
|
||||
encodeVql(value.length),
|
||||
...value.map((item) => encodeZcodeValue(item)),
|
||||
]);
|
||||
}
|
||||
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) {
|
||||
return Buffer.concat([Buffer.from([6]), encodeVql(value)]);
|
||||
}
|
||||
if (typeof value === "bigint" || typeof value === "function" || typeof value === "symbol") {
|
||||
throw new Error(`Unsupported ZCode protocol value type: ${typeof value}`);
|
||||
}
|
||||
const bytes = Buffer.from(JSON.stringify(value), "utf8");
|
||||
return Buffer.concat([Buffer.from([5]), encodeVql(bytes.byteLength), bytes]);
|
||||
}
|
||||
|
||||
/** Decode one value from ZCode's SocketProtocol value encoding. */
|
||||
export function decodeZcodeValue(data: Uint8Array, offset = 0): DecodedValue {
|
||||
if (offset >= data.byteLength) throw new Error("Truncated ZCode serialized value");
|
||||
const type = data[offset++];
|
||||
if (type === 0) return { value: undefined, offset };
|
||||
if (type === 1 || type === 2) {
|
||||
const length = decodeVql(data, offset);
|
||||
const end = length.offset + length.value;
|
||||
if (end > data.byteLength) throw new Error("Truncated ZCode byte/string value");
|
||||
const bytes = data.slice(length.offset, end);
|
||||
return {
|
||||
value: type === 1 ? Buffer.from(bytes).toString("utf8") : Buffer.from(bytes),
|
||||
offset: end,
|
||||
};
|
||||
}
|
||||
if (type === 4) {
|
||||
const length = decodeVql(data, offset);
|
||||
const values: unknown[] = [];
|
||||
let cursor = length.offset;
|
||||
for (let i = 0; i < length.value; i += 1) {
|
||||
const decoded = decodeZcodeValue(data, cursor);
|
||||
values.push(decoded.value);
|
||||
cursor = decoded.offset;
|
||||
}
|
||||
return { value: values, offset: cursor };
|
||||
}
|
||||
if (type === 5) {
|
||||
const length = decodeVql(data, offset);
|
||||
const end = length.offset + length.value;
|
||||
if (end > data.byteLength) throw new Error("Truncated ZCode JSON value");
|
||||
return {
|
||||
value: JSON.parse(Buffer.from(data.slice(length.offset, end)).toString("utf8")),
|
||||
offset: end,
|
||||
};
|
||||
}
|
||||
if (type === 6) {
|
||||
const decoded = decodeVql(data, offset);
|
||||
return { value: decoded.value, offset: decoded.offset };
|
||||
}
|
||||
throw new Error(`Unknown ZCode serialized value type ${type}`);
|
||||
}
|
||||
|
||||
export function encodeZcodeRpcCall(
|
||||
id: number,
|
||||
channel: string,
|
||||
method: string,
|
||||
args: unknown[]
|
||||
): Buffer {
|
||||
const body = Buffer.concat([
|
||||
encodeZcodeValue([100, id, channel, method]),
|
||||
encodeZcodeValue(args),
|
||||
]);
|
||||
const frame = Buffer.alloc(HEADER_SIZE + body.byteLength);
|
||||
frame.writeUInt8(REGULAR_MESSAGE, 0);
|
||||
frame.writeUInt32BE(0, 1);
|
||||
frame.writeUInt32BE(0, 5);
|
||||
frame.writeUInt32BE(body.byteLength, 9);
|
||||
body.copy(frame, HEADER_SIZE);
|
||||
return frame;
|
||||
}
|
||||
|
||||
function errorFromPayload(payload: unknown, fallback: string): Error {
|
||||
if (payload && typeof payload === "object") {
|
||||
const record = payload as JsonRecord;
|
||||
const message = typeof record.message === "string" ? record.message : fallback;
|
||||
const error = new Error(message);
|
||||
if (typeof record.code === "string") Object.assign(error, { code: record.code });
|
||||
if (record.data !== undefined) Object.assign(error, { data: record.data });
|
||||
return error;
|
||||
}
|
||||
return new Error(fallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Local stdio client for the ZCode app-server. The protocol starts with a JSON
|
||||
* hello line and then switches to 13-byte length-prefixed binary frames.
|
||||
*/
|
||||
export class ZcodeAppServerClient implements ZcodeClientLike {
|
||||
private readonly command: string;
|
||||
private readonly args: string[];
|
||||
private readonly cwd?: string;
|
||||
private readonly env?: NodeJS.ProcessEnv;
|
||||
private readonly startupTimeoutMs: number;
|
||||
private readonly requestTimeoutMs: number;
|
||||
private child?: ChildProcessWithoutNullStreams;
|
||||
private outputBuffer = Buffer.alloc(0);
|
||||
private handshakeDone = false;
|
||||
private ready = false;
|
||||
private startPromise?: Promise<void>;
|
||||
private serverReady?: () => void;
|
||||
private serverReadyError?: (error: Error) => void;
|
||||
private nextRequestId = 1;
|
||||
private readonly pending = new Map<number, PendingRequest>();
|
||||
|
||||
constructor(options: ZcodeAppServerClientOptions) {
|
||||
this.command = options.command;
|
||||
this.args = options.args ?? [];
|
||||
this.cwd = options.cwd;
|
||||
this.env = options.env;
|
||||
this.startupTimeoutMs = options.startupTimeoutMs ?? 10_000;
|
||||
this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.ready) return;
|
||||
if (this.startPromise) return this.startPromise;
|
||||
this.startPromise = this.startInternal().finally(() => {
|
||||
this.startPromise = undefined;
|
||||
});
|
||||
return this.startPromise;
|
||||
}
|
||||
|
||||
private async startInternal(): Promise<void> {
|
||||
let child: ChildProcessWithoutNullStreams;
|
||||
try {
|
||||
child = spawn(this.command, this.args, {
|
||||
cwd: this.cwd,
|
||||
env: this.env ? { ...process.env, ...this.env } : process.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch (error) {
|
||||
throw error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
this.child = child;
|
||||
this.outputBuffer = Buffer.alloc(0);
|
||||
this.handshakeDone = false;
|
||||
this.ready = false;
|
||||
child.stdin.on("error", () => {
|
||||
// EPIPE is expected when timeout/abort closes an already-exited runtime.
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
const readyPromise = new Promise<void>((resolve, reject) => {
|
||||
this.serverReady = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve();
|
||||
};
|
||||
this.serverReadyError = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(error);
|
||||
};
|
||||
});
|
||||
|
||||
child.stdout.on("data", (chunk: Buffer) => this.onStdout(chunk));
|
||||
child.stderr.on("data", () => {
|
||||
// ZCode stderr is intentionally not forwarded: it can contain provider
|
||||
// diagnostics or credentials from the user's local runtime.
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
this.serverReadyError?.(error);
|
||||
this.rejectPending(error);
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
const error = new Error(`ZCode app-server exited: ${code ?? signal ?? "unknown"}`);
|
||||
this.ready = false;
|
||||
this.handshakeDone = false;
|
||||
this.serverReadyError?.(error);
|
||||
this.rejectPending(error);
|
||||
if (this.child === child) this.child = undefined;
|
||||
});
|
||||
|
||||
try {
|
||||
await this.withTimeout(readyPromise, this.startupTimeoutMs, "ZCode app-server handshake timed out");
|
||||
this.ready = true;
|
||||
} catch (error) {
|
||||
await this.disposeChild(child);
|
||||
throw error instanceof Error ? error : new Error(String(error));
|
||||
} finally {
|
||||
this.serverReady = undefined;
|
||||
this.serverReadyError = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private onStdout(chunk: Buffer): void {
|
||||
this.outputBuffer = Buffer.concat([this.outputBuffer, chunk]);
|
||||
if (!this.handshakeDone) {
|
||||
const newline = this.outputBuffer.indexOf(0x0a);
|
||||
if (newline < 0) {
|
||||
if (this.outputBuffer.byteLength > 64 * 1024) {
|
||||
this.serverReadyError?.(new Error("ZCode hello line is too large"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const line = this.outputBuffer.subarray(0, newline).toString("utf8").trim();
|
||||
this.outputBuffer = this.outputBuffer.subarray(newline + 1);
|
||||
let hello: unknown;
|
||||
try {
|
||||
hello = JSON.parse(line);
|
||||
} catch {
|
||||
this.serverReadyError?.(new Error("Invalid ZCode app-server hello"));
|
||||
return;
|
||||
}
|
||||
if (!hello || typeof hello !== "object" || (hello as JsonRecord).type !== "zcode-hello") {
|
||||
this.serverReadyError?.(new Error("Unexpected ZCode app-server hello"));
|
||||
return;
|
||||
}
|
||||
const child = this.child;
|
||||
if (!child) return;
|
||||
child.stdin.write(`${JSON.stringify({
|
||||
type: "zcode-hello-ack",
|
||||
version: "omniroute",
|
||||
clientId: `omniroute-${process.pid}`,
|
||||
})}\n`);
|
||||
this.handshakeDone = true;
|
||||
}
|
||||
this.consumeFrames();
|
||||
}
|
||||
|
||||
private consumeFrames(): void {
|
||||
while (this.outputBuffer.byteLength >= HEADER_SIZE) {
|
||||
const type = this.outputBuffer.readUInt8(0);
|
||||
const length = this.outputBuffer.readUInt32BE(9);
|
||||
if (length > MAX_FRAME_BYTES) {
|
||||
const error = new Error("ZCode frame exceeds the configured safety limit");
|
||||
this.serverReadyError?.(error);
|
||||
this.rejectPending(error);
|
||||
return;
|
||||
}
|
||||
const frameLength = HEADER_SIZE + length;
|
||||
if (this.outputBuffer.byteLength < frameLength) return;
|
||||
const body = this.outputBuffer.subarray(HEADER_SIZE, frameLength);
|
||||
this.outputBuffer = this.outputBuffer.subarray(frameLength);
|
||||
if (type !== REGULAR_MESSAGE) continue;
|
||||
try {
|
||||
const header = decodeZcodeValue(body, 0);
|
||||
const payload = decodeZcodeValue(body, header.offset);
|
||||
this.handleMessage(header.value, payload.value);
|
||||
} catch (error) {
|
||||
const normalized = error instanceof Error ? error : new Error(String(error));
|
||||
this.serverReadyError?.(normalized);
|
||||
this.rejectPending(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(headerValue: unknown, payload: unknown): void {
|
||||
if (!Array.isArray(headerValue)) return;
|
||||
const type = headerValue[0];
|
||||
if (type === INITIALIZE_MESSAGE) {
|
||||
this.serverReady?.();
|
||||
return;
|
||||
}
|
||||
if (type !== RESPONSE_MESSAGE && type !== ERROR_MESSAGE && type !== CANCELED_MESSAGE) return;
|
||||
const requestId = headerValue[1];
|
||||
if (typeof requestId !== "number") return;
|
||||
const request = this.pending.get(requestId);
|
||||
if (!request) return;
|
||||
this.pending.delete(requestId);
|
||||
clearTimeout(request.timer);
|
||||
if (type === RESPONSE_MESSAGE) {
|
||||
request.resolve(payload);
|
||||
} else {
|
||||
request.reject(errorFromPayload(
|
||||
payload,
|
||||
type === ERROR_MESSAGE ? "ZCode RPC request failed" : "ZCode RPC request canceled"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
async call(channel: string, method: string, args: unknown[]): Promise<unknown> {
|
||||
await this.start();
|
||||
const child = this.child;
|
||||
if (!child || !this.ready) throw new Error("ZCode app-server is not ready");
|
||||
const requestId = this.nextRequestId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(requestId);
|
||||
reject(new Error(`ZCode RPC request timed out: ${channel}.${method}`));
|
||||
}, this.requestTimeoutMs);
|
||||
timer.unref?.();
|
||||
this.pending.set(requestId, { resolve, reject, timer });
|
||||
try {
|
||||
child.stdin.write(encodeZcodeRpcCall(requestId, channel, method, args));
|
||||
} catch (error) {
|
||||
clearTimeout(timer);
|
||||
this.pending.delete(requestId);
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
const child = this.child;
|
||||
this.ready = false;
|
||||
this.handshakeDone = false;
|
||||
this.child = undefined;
|
||||
this.serverReadyError?.(new Error("ZCode app-server closed"));
|
||||
this.rejectPending(new Error("ZCode app-server closed"));
|
||||
if (child) await this.disposeChild(child);
|
||||
}
|
||||
|
||||
private rejectPending(error: Error): void {
|
||||
for (const [id, pending] of this.pending) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(error);
|
||||
this.pending.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
private async disposeChild(child: ChildProcessWithoutNullStreams): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
const exited = new Promise<void>((resolve) => child.once("close", () => resolve()));
|
||||
try {
|
||||
child.stdin.end();
|
||||
} catch {
|
||||
// The process may already have closed stdin.
|
||||
}
|
||||
if (!child.killed) child.kill("SIGTERM");
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
await Promise.race([
|
||||
exited,
|
||||
new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, 1500);
|
||||
timer.unref?.();
|
||||
}),
|
||||
]);
|
||||
if (timer) clearTimeout(timer);
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill("SIGKILL");
|
||||
await exited;
|
||||
}
|
||||
}
|
||||
|
||||
private async withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
timer.unref?.();
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,15 @@ const AGENT_DEFINITIONS: Omit<CliAgentInfo, "version" | "installed">[] = [
|
||||
spawnArgs: ["--no-auto-commits"],
|
||||
protocol: "stdio",
|
||||
},
|
||||
{
|
||||
id: "zcode",
|
||||
name: "ZCode (GLM Coding Plan)",
|
||||
binary: "zcode",
|
||||
versionCommand: "zcode --version",
|
||||
providerAlias: "zcode",
|
||||
spawnArgs: ["app-server"],
|
||||
protocol: "stdio",
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
|
||||
@@ -85,6 +85,25 @@ export const CLI_TOOLS: Record<string, CliCatalogEntry> = {
|
||||
baseUrlSupport: "full",
|
||||
defaultCommand: "codex",
|
||||
},
|
||||
zcode: {
|
||||
id: "zcode",
|
||||
name: "ZCode (GLM Coding Plan)",
|
||||
color: "#3B82F6",
|
||||
description: "Local ZCode app-server backend; auth remains in the user's ZCode profile",
|
||||
docsUrl: "https://zcode.z.ai",
|
||||
configType: "custom",
|
||||
category: "code",
|
||||
vendor: "Z.ai",
|
||||
// ZCode's app-server is a native length-prefixed protocol, not ACP. The
|
||||
// zcode provider executor owns its lifecycle instead of ACP spawning it.
|
||||
acpSpawnable: false,
|
||||
baseUrlSupport: "none",
|
||||
defaultCommand: "zcode",
|
||||
notes: [
|
||||
{ type: "info", text: "Uses the local ZCode app-server and its existing builtin:zai-coding-plan login." },
|
||||
{ type: "warning", text: "The response is buffered until the ZCode turn completes." },
|
||||
],
|
||||
},
|
||||
droid: {
|
||||
id: "droid",
|
||||
name: "Factory Droid",
|
||||
|
||||
@@ -38,6 +38,8 @@ export const FREE_APIKEY_PROVIDER_IDS = new Set([
|
||||
// accepts an optional connection row for display/priority/testStatus tracking —
|
||||
// no apiKey is ever required or sent upstream.
|
||||
"auggie",
|
||||
// zcode is a local app-server backend; auth stays in the ZCode profile.
|
||||
"zcode",
|
||||
]);
|
||||
|
||||
export function supportsApiKeyOnFreeProvider(providerId: unknown): boolean {
|
||||
|
||||
@@ -157,6 +157,24 @@ export const NOAUTH_PROVIDERS = {
|
||||
text: "Augment (Auggie CLI) requires the `auggie` binary installed and authenticated locally (`auggie login`). OmniRoute spawns it as a subprocess and never sees or stores your Augment credentials.",
|
||||
},
|
||||
},
|
||||
zcode: {
|
||||
id: "zcode",
|
||||
alias: "zc",
|
||||
name: "ZCode (GLM Coding Plan)",
|
||||
icon: "terminal",
|
||||
color: "#3B82F6",
|
||||
textIcon: "ZC",
|
||||
website: "https://zcode.z.ai",
|
||||
noAuth: true,
|
||||
hasFree: false,
|
||||
serviceKinds: ["llm"],
|
||||
isLocalCli: true,
|
||||
authHint:
|
||||
"No API key stored by OmniRoute. The local ZCode app-server uses the existing builtin:zai-coding-plan login.",
|
||||
notice: {
|
||||
text: "ZCode runs locally through its native app-server. OmniRoute never receives or stores the Z.ai credential.",
|
||||
},
|
||||
},
|
||||
aihorde: {
|
||||
id: "aihorde",
|
||||
alias: "horde",
|
||||
|
||||
@@ -93,6 +93,17 @@ const CLI_TOOLS: Record<string, any> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
zcode: {
|
||||
defaultCommand: "zcode",
|
||||
envBinKey: "ZCODE_BIN",
|
||||
requiresBinary: true,
|
||||
// The app-server performs a local runtime handshake and can be slower on
|
||||
// the first launch while the user's ZCode profile is loaded.
|
||||
healthcheckTimeoutMs: 15000,
|
||||
paths: {
|
||||
config: ".zcode",
|
||||
},
|
||||
},
|
||||
cline: {
|
||||
defaultCommand: "cline",
|
||||
envBinKey: "CLI_CLINE_BIN",
|
||||
|
||||
167
tests/fixtures/fake-zcode-app-server.mjs
vendored
Normal file
167
tests/fixtures/fake-zcode-app-server.mjs
vendored
Normal file
@@ -0,0 +1,167 @@
|
||||
const HEADER_SIZE = 13;
|
||||
let input = Buffer.alloc(0);
|
||||
let handshaken = false;
|
||||
let sessionId = "fake-zcode-session";
|
||||
let selectedModel = null;
|
||||
|
||||
function vql(value) {
|
||||
if (!Number.isSafeInteger(value) || value < 0) throw new Error("invalid vql value");
|
||||
const bytes = [];
|
||||
let remaining = value;
|
||||
do {
|
||||
let next = remaining % 128;
|
||||
remaining = Math.floor(remaining / 128);
|
||||
if (remaining > 0) next |= 0x80;
|
||||
bytes.push(next);
|
||||
} while (remaining > 0);
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
function encode(value) {
|
||||
if (value === undefined) return Buffer.from([0]);
|
||||
if (typeof value === "string") {
|
||||
const bytes = Buffer.from(value, "utf8");
|
||||
return Buffer.concat([Buffer.from([1]), vql(bytes.length), bytes]);
|
||||
}
|
||||
if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
|
||||
const bytes = Buffer.from(value);
|
||||
return Buffer.concat([Buffer.from([2]), vql(bytes.length), bytes]);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return Buffer.concat([Buffer.from([4]), vql(value.length), ...value.map(encode)]);
|
||||
}
|
||||
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) {
|
||||
return Buffer.concat([Buffer.from([6]), vql(value)]);
|
||||
}
|
||||
const bytes = Buffer.from(JSON.stringify(value), "utf8");
|
||||
return Buffer.concat([Buffer.from([5]), vql(bytes.length), bytes]);
|
||||
}
|
||||
|
||||
function readVql(data, state) {
|
||||
let value = 0;
|
||||
let multiplier = 1;
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
if (state.offset >= data.length) throw new Error("truncated vql");
|
||||
const next = data[state.offset++];
|
||||
value += (next & 0x7f) * multiplier;
|
||||
if ((next & 0x80) === 0) return value;
|
||||
multiplier *= 128;
|
||||
}
|
||||
throw new Error("invalid vql");
|
||||
}
|
||||
|
||||
function decode(data, state) {
|
||||
const type = data[state.offset++];
|
||||
if (type === 0) return undefined;
|
||||
if (type === 1 || type === 2) {
|
||||
const length = readVql(data, state);
|
||||
const end = state.offset + length;
|
||||
if (end > data.length) throw new Error("truncated bytes");
|
||||
const bytes = data.subarray(state.offset, end);
|
||||
state.offset = end;
|
||||
return type === 1 ? bytes.toString("utf8") : bytes;
|
||||
}
|
||||
if (type === 4) {
|
||||
const length = readVql(data, state);
|
||||
return Array.from({ length }, () => decode(data, state));
|
||||
}
|
||||
if (type === 5) {
|
||||
const length = readVql(data, state);
|
||||
const end = state.offset + length;
|
||||
const value = JSON.parse(data.subarray(state.offset, end).toString("utf8"));
|
||||
state.offset = end;
|
||||
return value;
|
||||
}
|
||||
if (type === 6) return readVql(data, state);
|
||||
throw new Error(`unknown type ${type}`);
|
||||
}
|
||||
|
||||
function frame(body) {
|
||||
const result = Buffer.alloc(HEADER_SIZE + body.length);
|
||||
result.writeUInt8(1, 0);
|
||||
result.writeUInt32BE(0, 1);
|
||||
result.writeUInt32BE(0, 5);
|
||||
result.writeUInt32BE(body.length, 9);
|
||||
body.copy(result, HEADER_SIZE);
|
||||
return result;
|
||||
}
|
||||
|
||||
function send(header, payload) {
|
||||
const packet = frame(Buffer.concat([encode(header), encode(payload)]));
|
||||
process.stdout.write(packet.subarray(0, 5));
|
||||
setTimeout(() => process.stdout.write(packet.subarray(5)), 1);
|
||||
}
|
||||
|
||||
function response(id, payload) {
|
||||
send([201, id], payload);
|
||||
}
|
||||
|
||||
function handleFrame(body) {
|
||||
const state = { offset: 0 };
|
||||
const header = decode(body, state);
|
||||
const args = decode(body, state);
|
||||
const id = Array.isArray(header) ? header[1] : undefined;
|
||||
const method = Array.isArray(header) ? header[3] : undefined;
|
||||
const request = Array.isArray(args) && args[0] && typeof args[0] === "object" ? args[0] : {};
|
||||
|
||||
switch (method) {
|
||||
case "initialize":
|
||||
response(id, { available: true, protocolName: "ZCode Protocol", protocolVersion: 1, transportKind: "stdio" });
|
||||
break;
|
||||
case "createSession":
|
||||
sessionId = "fake-zcode-session";
|
||||
response(id, { session: { sessionId, status: "idle", workspace: { workspacePath: request.workspacePath } }, messages: [] });
|
||||
break;
|
||||
case "setModel":
|
||||
selectedModel = request.model;
|
||||
response(id, { ok: true, model: selectedModel });
|
||||
break;
|
||||
case "sendPrompt":
|
||||
response(id, { session: { sessionId, status: "running" }, accepted: true });
|
||||
break;
|
||||
case "readSession":
|
||||
response(id, {
|
||||
session: { sessionId, status: "completed", model: selectedModel },
|
||||
messages: [
|
||||
{ info: { messageId: "fake-user-message", role: "user" }, parts: [{ type: "text", text: request.content || "prompt" }] },
|
||||
{ info: { messageId: "fake-assistant-message", role: "assistant" }, parts: [{ type: "text", text: "fake zcode response" }] },
|
||||
],
|
||||
});
|
||||
break;
|
||||
case "closeSession":
|
||||
response(id, { ok: true });
|
||||
break;
|
||||
default:
|
||||
response(id, { ok: true });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function consumeFrames() {
|
||||
while (input.length >= HEADER_SIZE) {
|
||||
const length = input.readUInt32BE(9);
|
||||
const total = HEADER_SIZE + length;
|
||||
if (input.length < total) return;
|
||||
const body = input.subarray(HEADER_SIZE, total);
|
||||
input = input.subarray(total);
|
||||
handleFrame(body);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`${JSON.stringify({ type: "zcode-hello", version: "fixture", platform: "test", arch: "test", pid: process.pid })}\n`);
|
||||
|
||||
process.stdin.on("data", (chunk) => {
|
||||
input = Buffer.concat([input, chunk]);
|
||||
if (!handshaken) {
|
||||
const newline = input.indexOf(0x0a);
|
||||
if (newline < 0) return;
|
||||
const ack = JSON.parse(input.subarray(0, newline).toString("utf8"));
|
||||
if (ack.type !== "zcode-hello-ack") throw new Error("missing ZCode hello ack");
|
||||
input = input.subarray(newline + 1);
|
||||
handshaken = true;
|
||||
send([200], undefined);
|
||||
}
|
||||
consumeFrames();
|
||||
});
|
||||
|
||||
process.stdin.on("end", () => process.exit(0));
|
||||
@@ -6156,6 +6156,29 @@
|
||||
"stream": "https://chat.z.ai"
|
||||
}
|
||||
},
|
||||
"zcode": {
|
||||
"format": "openai",
|
||||
"headers": {
|
||||
"apiKey": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"nonStream": "zcode://app-server/stdio",
|
||||
"stream": "zcode://app-server/stdio"
|
||||
}
|
||||
},
|
||||
"zed-hosted": {
|
||||
"format": "openai",
|
||||
"headers": {
|
||||
|
||||
@@ -141,7 +141,10 @@ describe("auto/<family> materialization (#6453)", () => {
|
||||
// `devin-cli-agentic` joined for the same documented reason as `auggie`:
|
||||
// #8914 added the Devin ACP bridge whose catalog (registry/devin/catalog.ts)
|
||||
// advertises the glm-5-2* line, so it genuinely serves the family.
|
||||
assert.deepEqual(providerIds, ["auggie", "devin-cli-agentic", "glm", "zai"]);
|
||||
// `zcode` joined for the same documented reason too — #10184 added the local
|
||||
// ZCode app-server backend whose registry (registry/zcode) advertises the
|
||||
// full GLM_SHARED_MODELS line-up, so it genuinely serves the family.
|
||||
assert.deepEqual(providerIds, ["auggie", "devin-cli-agentic", "glm", "zai", "zcode"]);
|
||||
// Every candidate must be a glm-family model (the Cartesian pool now surfaces
|
||||
// each backend's full glm line-up, not only the glm-5.2 default), and the
|
||||
// connected openai/gpt-4o-mini backend must be excluded — same family
|
||||
|
||||
84
tests/unit/zcode-executor.test.ts
Normal file
84
tests/unit/zcode-executor.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const fixture = join(process.cwd(), "tests/fixtures/fake-zcode-app-server.mjs");
|
||||
const TEST_DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-zcode-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
test.after(() => rmSync(TEST_DATA_DIR, { recursive: true, force: true }));
|
||||
|
||||
async function loadZcodeExecutor() {
|
||||
return import("../../open-sse/executors/zcode.ts");
|
||||
}
|
||||
|
||||
function requestBody() {
|
||||
return {
|
||||
messages: [
|
||||
{ role: "system", content: "You are a coding assistant." },
|
||||
{ role: "user", content: "Reply with a short status." },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test("ZCode accepts GLM Coding Plan models and rejects unsafe/unknown ids", async () => {
|
||||
const { resolveZcodeModel } = await loadZcodeExecutor();
|
||||
assert.deepEqual(resolveZcodeModel("glm-5.2"), { ok: true, model: "glm-5.2" });
|
||||
assert.equal(resolveZcodeModel("-unexpected").ok, false);
|
||||
assert.equal(resolveZcodeModel("unknown-model").ok, false);
|
||||
});
|
||||
|
||||
test("ZCode runs a local app-server turn and returns an OpenAI chat completion", async () => {
|
||||
const { ZcodeExecutor } = await loadZcodeExecutor();
|
||||
const executor = new ZcodeExecutor({
|
||||
command: process.execPath,
|
||||
args: [fixture],
|
||||
cwd: process.cwd(),
|
||||
requestTimeoutMs: 3000,
|
||||
turnTimeoutMs: 3000,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
|
||||
const result = await executor.execute({
|
||||
model: "glm-5.2",
|
||||
body: requestBody(),
|
||||
stream: false,
|
||||
credentials: {},
|
||||
});
|
||||
const response = "response" in result ? result.response : result;
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(response.headers.get("content-type") || "", /application\/json/);
|
||||
const body = await response.json();
|
||||
assert.equal(body.object, "chat.completion");
|
||||
assert.equal(body.model, "glm-5.2");
|
||||
assert.equal(body.choices?.[0]?.message?.role, "assistant");
|
||||
assert.equal(body.choices?.[0]?.message?.content, "fake zcode response");
|
||||
assert.equal(body.choices?.[0]?.finish_reason, "stop");
|
||||
});
|
||||
|
||||
test("ZCode buffers the completed turn into OpenAI SSE when stream=true", async () => {
|
||||
const { ZcodeExecutor } = await loadZcodeExecutor();
|
||||
const executor = new ZcodeExecutor({
|
||||
command: process.execPath,
|
||||
args: [fixture],
|
||||
cwd: process.cwd(),
|
||||
requestTimeoutMs: 3000,
|
||||
turnTimeoutMs: 3000,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
|
||||
const result = await executor.execute({
|
||||
model: "glm-5.2-high",
|
||||
body: requestBody(),
|
||||
stream: true,
|
||||
credentials: {},
|
||||
});
|
||||
const response = "response" in result ? result.response : result;
|
||||
const text = await response.text();
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(response.headers.get("content-type") || "", /text\/event-stream/);
|
||||
assert.match(text, /fake zcode response/);
|
||||
assert.match(text, /data: \[DONE\]/);
|
||||
});
|
||||
30
tests/unit/zcode-protocol.test.ts
Normal file
30
tests/unit/zcode-protocol.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { join } from "node:path";
|
||||
import { ZcodeAppServerClient } from "../../open-sse/executors/zcodeProtocol.ts";
|
||||
|
||||
const fixture = join(process.cwd(), "tests/fixtures/fake-zcode-app-server.mjs");
|
||||
|
||||
test("ZCode protocol performs hello handshake and exchanges fragmented framed RPC", async () => {
|
||||
const client = new ZcodeAppServerClient({
|
||||
command: process.execPath,
|
||||
args: [fixture],
|
||||
cwd: process.cwd(),
|
||||
startupTimeoutMs: 3000,
|
||||
requestTimeoutMs: 3000,
|
||||
});
|
||||
try {
|
||||
await client.start();
|
||||
const result = await client.call("zcode-agent", "initialize", [
|
||||
{ workspacePath: "/workspace", workspaceIdentity: "/workspace" },
|
||||
]);
|
||||
assert.deepEqual(result, {
|
||||
available: true,
|
||||
protocolName: "ZCode Protocol",
|
||||
protocolVersion: 1,
|
||||
transportKind: "stdio",
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
14
tests/unit/zcode-provider.test.ts
Normal file
14
tests/unit/zcode-provider.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { zcodeProvider } from "../../open-sse/config/providers/registry/zcode/index.ts";
|
||||
|
||||
test("ZCode provider registry exposes a local no-auth GLM Coding Plan backend", () => {
|
||||
assert.equal(zcodeProvider.id, "zcode");
|
||||
assert.equal(zcodeProvider.alias, "zc");
|
||||
assert.equal(zcodeProvider.executor, "zcode");
|
||||
assert.equal(zcodeProvider.format, "openai");
|
||||
assert.equal(zcodeProvider.baseUrl, "zcode://app-server/stdio");
|
||||
assert.equal(zcodeProvider.authType, "none");
|
||||
assert.equal(zcodeProvider.authHeader, "none");
|
||||
assert.equal(zcodeProvider.models.some((model) => model.id === "glm-5.2"), true);
|
||||
});
|
||||
Reference in New Issue
Block a user