diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51f7e23..e0ef59d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,7 +129,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 24 + node-version: 24.22.1 - name: Download package artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index f189296..07500b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- `--inference-url ` and `ZRO_INFERENCE_URL` point inference at another + OpenAI-compatible gateway. A root or `/v1` URL works, and the override wins over + `ZRO_ENDPOINT_ROOT` and the default endpoint. Login, the authenticated catalog, + feedback, and MCP stay on the control-plane URL. +- `--no-auth` and `ZRO_NO_AUTH=1` launch agents against an inference endpoint with no Zro + account. No-auth mode requires an explicit inference override, skips key resolution + and Authorization headers, loads public model metadata from + `/api/cli/models/public`, intersects it with the gateway's live `/v1/models` list, + caches it separately as `model-catalog-public.json`, and omits the Zro MCP server. + `zro models` and `zro status` work in this mode; `zro feedback` does not. + ### Fixed - Prime Agent 0.10.1 launches now use its `settings.json` MCP format and no longer pass the diff --git a/README.md b/README.md index 0ec66a3..a463ce8 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,40 @@ zro logout # remove the stored login Codex App keeps its own copy under `~/.config/zro/codex-app/.env`; `zro logout` removes both. - The credential is verified against the inference API before any launch. +### Alternate inference URL and no-auth mode + +Point inference at another OpenAI-compatible gateway with `--inference-url` or +`ZRO_INFERENCE_URL`. A root URL or one ending in `/v1` both work; trailing slashes are +normalized. + +```bash +zro claude --inference-url https://gateway.example +ZRO_INFERENCE_URL=https://gateway.example zro codex +``` + +Precedence is `--inference-url` > `ZRO_INFERENCE_URL` > `ZRO_ENDPOINT_ROOT` > +`https://zro.moonmath.ai`. The override only changes inference traffic; login, the +authenticated catalog, feedback, and MCP stay on `ZRO_AUTH_URL` / `ZRO_ENDPOINT_ROOT`. + +For an inference endpoint with no authentication and no Zro account, add `--no-auth` or +set `ZRO_NO_AUTH=1`. No-auth mode requires an explicit `--inference-url` or +`ZRO_INFERENCE_URL`. + +```bash +zro claude --no-auth --inference-url https://gateway.example +zro models --no-auth --inference-url https://gateway.example +ZRO_NO_AUTH=1 ZRO_INFERENCE_URL=https://gateway.example zro status +``` + +In no-auth mode the CLI skips login and API-key checks, sends no `Authorization` +headers, and uses a fixed non-secret placeholder key only where an agent requires a +non-empty value. Public model metadata comes from the main webapp +(`/api/cli/models/public`) and is intersected with the gateway's live `/v1/models` +list, so `zro models` only shows models the gateway can serve. That metadata is cached +separately at `~/.cache/zro/model-catalog-public.json`. The Zro MCP server is omitted +from every agent config because it requires an account, and `zro feedback` is +unavailable in this mode. + --- ## Supported coding agents @@ -116,6 +150,8 @@ zro install claude@2.1.105 # pin an agent version zro feedback # send feedback to the Zro team zro claude --dry-run # secret-safe session preview zro codex --json # machine-readable preview +zro claude --no-auth --inference-url https://gateway.example + # launch against a no-auth inference endpoint ``` --- diff --git a/scripts/ci/check-live.mjs b/scripts/ci/check-live.mjs index 6a1a914..d0fc12a 100644 --- a/scripts/ci/check-live.mjs +++ b/scripts/ci/check-live.mjs @@ -162,7 +162,7 @@ async function collectVersions() { }; for (const name of harnesses) { const command = clientCommands[name]; - const result = await run(command[0], command.slice(1), `${name} version`, 30_000); + const result = await run(command[0], command.slice(1), `${name} version`, 30_000, process.env); report.clients[name] = result.stdout.split(/\r?\n/).find((line) => line.trim())?.trim() || "installed"; } } diff --git a/src/args.ts b/src/args.ts index 7b153f4..9765cc1 100644 --- a/src/args.ts +++ b/src/args.ts @@ -1,4 +1,5 @@ import { resolveTool, TOOLS } from "./catalog.js"; +import { normalizeInferenceRoot } from "./endpoints.js"; import type { CliRequest, OutputMode } from "./types.js"; const HELP = new Set(["help", "-h", "--help"]); @@ -41,23 +42,29 @@ export function parseArgs(argv: string[]): CliRequest { const rest = command === "auth" && argv[1] === "status" ? argv.slice(2) : argv.slice(1); const parsed = parseOptions(rest, false); assertNoExtraArgs(parsed.extraArgs, command); - return { command: "status", output: parsed.output }; + return { command: "status", output: parsed.output, ...endpointOptions(parsed) }; } if (command === "models") { const parsed = parseOptions(argv.slice(1), false); assertNoExtraArgs(parsed.extraArgs, command); - return { command: "models", output: parsed.output }; + return { command: "models", output: parsed.output, ...endpointOptions(parsed) }; } if (command === "install") return parseInstallArgs(argv.slice(1)); if (command === "again") { const parsed = parseOptions(argv.slice(1), false); assertNoExtraArgs(parsed.extraArgs, command); - return { command: "again", dryRun: parsed.dryRun, output: parsed.output, aliases: parsed.aliases }; + return { + command: "again", + dryRun: parsed.dryRun, + output: parsed.output, + aliases: parsed.aliases, + ...endpointOptions(parsed), + }; } if (command === "feedback") { const parsed = parseOptions(argv.slice(1), false); const message = parsed.extraArgs.join(" ").trim() || undefined; - return { command: "feedback", message, output: parsed.output }; + return { command: "feedback", message, output: parsed.output, ...endpointOptions(parsed) }; } if (command === "launch" || command === "run") { @@ -78,6 +85,8 @@ export function parseArgs(argv: string[]): CliRequest { interface ParsedOptions { model?: string; apiKey?: string; + inferenceUrl?: string; + noAuth?: boolean; dryRun: boolean; install?: boolean; output: OutputMode; @@ -127,6 +136,21 @@ function parseOptions(argv: string[], allowPassthrough: boolean): ParsedOptions result.apiKey = value.slice("--api-key=".length); continue; } + if (value === "--inference-url") { + result.inferenceUrl = normalizeInferenceRoot(requiredValue(argv, ++index, value), "--inference-url"); + continue; + } + if (value.startsWith("--inference-url=")) { + result.inferenceUrl = normalizeInferenceRoot( + value.slice("--inference-url=".length), + "--inference-url", + ); + continue; + } + if (value === "--no-auth") { + result.noAuth = true; + continue; + } if (value === "--dry-run" || value === "-n") { result.dryRun = true; continue; @@ -211,6 +235,13 @@ function requiredValue(argv: string[], index: number, option: string): string { return value; } +function endpointOptions(options: ParsedOptions): Pick { + return { + ...(options.inferenceUrl ? { inferenceUrl: options.inferenceUrl } : {}), + ...(options.noAuth ? { noAuth: true } : {}), + }; +} + function outputMode(argv: string[]): OutputMode { return argv.includes("--json") ? "json" : "human"; } diff --git a/src/endpoints.ts b/src/endpoints.ts new file mode 100644 index 0000000..e622c76 --- /dev/null +++ b/src/endpoints.ts @@ -0,0 +1,57 @@ +import { DEFAULT_ENDPOINT_ROOT } from "./engine/constants.js"; + +export interface ResolvedInference { + root: string; + baseUrl: string; +} + +export function normalizeInferenceRoot(value: string, label = "inference URL"): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error(`Invalid ${label}: ${value}. Use an http(s) URL.`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`Invalid ${label}: ${value}. Use an http(s) URL.`); + } + if (url.search || url.hash) { + throw new Error(`Invalid ${label}: ${value}. Remove the query string and fragment.`); + } + + let pathname = url.pathname.replace(/\/+$/, ""); + if (/\/v1$/i.test(pathname)) pathname = pathname.slice(0, -3); + pathname = pathname.replace(/\/+$/, ""); + return `${url.origin}${pathname}`; +} + +export function resolveInference( + env: NodeJS.ProcessEnv, + flagValue?: string, + requireExplicit = false, +): ResolvedInference { + const explicit = flagValue ?? env.ZRO_INFERENCE_URL; + if (requireExplicit && !explicit) { + throw new Error( + "No-auth mode needs an inference URL. Pass --inference-url or set ZRO_INFERENCE_URL.", + ); + } + const raw = explicit ?? env.ZRO_ENDPOINT_ROOT ?? DEFAULT_ENDPOINT_ROOT; + const root = normalizeInferenceRoot(raw); + return { root, baseUrl: `${root}/v1` }; +} + +export function resolveControlPlaneRoot(env: NodeJS.ProcessEnv): string { + return normalizeInferenceRoot( + env.ZRO_AUTH_URL || env.ZRO_ENDPOINT_ROOT || DEFAULT_ENDPOINT_ROOT, + "control-plane URL", + ); +} + +export function resolveMcpUrl(env: NodeJS.ProcessEnv): string { + return env.ZRO_MCP_URL || `${resolveControlPlaneRoot(env)}/mcp/zro`; +} + +export function isNoAuth(requestValue: boolean | undefined, env: NodeJS.ProcessEnv): boolean { + return requestValue === true || env.ZRO_NO_AUTH === "1"; +} diff --git a/src/engine/constants.ts b/src/engine/constants.ts index f796706..97e9a13 100644 --- a/src/engine/constants.ts +++ b/src/engine/constants.ts @@ -1,7 +1,9 @@ -export const ENDPOINT_ROOT = process.env.ZRO_ENDPOINT_ROOT ?? "https://zro.moonmath.ai"; -export const BASE_URL = `${ENDPOINT_ROOT}/v1`; -export const MCP_URL = process.env.ZRO_MCP_URL ?? `${ENDPOINT_ROOT}/mcp/zro`; +export const DEFAULT_ENDPOINT_ROOT = "https://zro.moonmath.ai"; +export const ENDPOINT_ROOT = DEFAULT_ENDPOINT_ROOT; +export const BASE_URL = `${DEFAULT_ENDPOINT_ROOT}/v1`; +export const MCP_URL = `${DEFAULT_ENDPOINT_ROOT}/mcp/zro`; export const ZRO_ENV_KEY = "ZRO_API_KEY"; +export const NO_AUTH_PLACEHOLDER_KEY = "zro-no-auth"; export const PROVIDER_ID = "zro"; export const PROVIDER_NAME = "Zro"; diff --git a/src/engine/tools/claude.ts b/src/engine/tools/claude.ts index fd37e3b..b39f58a 100644 --- a/src/engine/tools/claude.ts +++ b/src/engine/tools/claude.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { ENDPOINT_ROOT, MCP_URL, PROVIDER_NAME, type ZroModel } from "../constants.js"; +import { PROVIDER_NAME, type ZroModel } from "../constants.js"; import { jsonSerializer } from "../serializers.js"; import type { ToolModule } from "../types.js"; @@ -26,14 +26,13 @@ export const claudeTool: ToolModule = { claudeModelId(ctx.model, claudeSpecFor(ctx.model, ctx.models), sessionBudget), "--managed-settings", buildClaudeManagedSettingsArg(ctx.models, aliasPlan), - "--mcp-config", - mcpConfigPath, + ...(ctx.noAuth ? [] : ["--mcp-config", mcpConfigPath]), ...ctx.extraArgs ], env: { - ANTHROPIC_BASE_URL: ENDPOINT_ROOT, + ANTHROPIC_BASE_URL: ctx.inferenceRoot, ANTHROPIC_AUTH_TOKEN: ctx.apiKey, - ...buildClaudeModelEnv(ctx.model, ctx.models, aliasPlan, sessionBudget, sessionOutputBudget), + ...buildClaudeModelEnv(ctx.model, ctx.models, aliasPlan, sessionBudget, sessionOutputBudget, ctx.inferenceRoot), CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "0", CLAUDE_CODE_ATTRIBUTION_HEADER: "0", CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING: "1", @@ -45,13 +44,13 @@ export const claudeTool: ToolModule = { CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY: "1", DO_NOT_TRACK: "1" }, - files: [{ + files: ctx.noAuth ? [] : [{ path: mcpConfigPath, contents: jsonSerializer.stringify({ mcpServers: { zro: { type: "http", - url: MCP_URL, + url: ctx.mcpUrl, headers: { Authorization: `Bearer ${ctx.apiKey}` } } } @@ -197,14 +196,15 @@ function buildClaudeModelEnv( modelSpecs: readonly ZroModel[], aliasPlan: ClaudeAliasPlan, sessionBudget: number | undefined, - sessionOutputBudget: number | undefined + sessionOutputBudget: number | undefined, + inferenceRoot: string ): Record { const aliasMapping = aliasPlan.mapping; const env: Record = { ANTHROPIC_CUSTOM_MODEL_OPTION: claudeModelId(selectedModel, claudeSpecFor(selectedModel, modelSpecs), sessionBudget), ANTHROPIC_CUSTOM_MODEL_OPTION_NAME: claudeModelName(selectedModel, modelSpecs, sessionBudget), - ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION: `${PROVIDER_NAME} model via ${ENDPOINT_ROOT}` + ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION: `${PROVIDER_NAME} model via ${inferenceRoot}` }; // aliasPlan.mapping is already filtered to models this catalog carries, so @@ -216,7 +216,7 @@ function buildClaudeModelEnv( sessionBudget ); env[`ANTHROPIC_DEFAULT_${slot}_MODEL_NAME`] = claudeModelName(modelId, modelSpecs, sessionBudget); - env[`ANTHROPIC_DEFAULT_${slot}_MODEL_DESCRIPTION`] = `${PROVIDER_NAME} model via ${ENDPOINT_ROOT}`; + env[`ANTHROPIC_DEFAULT_${slot}_MODEL_DESCRIPTION`] = `${PROVIDER_NAME} model via ${inferenceRoot}`; } if (sessionBudget !== undefined) { diff --git a/src/engine/tools/codex.ts b/src/engine/tools/codex.ts index d8e8c16..6efb926 100644 --- a/src/engine/tools/codex.ts +++ b/src/engine/tools/codex.ts @@ -25,12 +25,15 @@ export const codexTool: ToolModule = { path: path.join(codexHome, "config.toml"), contents: buildCodexConfig(ctx.model, { catalogPath, - modelSpec: selectedModel + modelSpec: selectedModel, + baseUrl: ctx.inferenceBaseUrl, + mcpUrl: ctx.mcpUrl, + noAuth: ctx.noAuth }) }, { path: catalogPath, - contents: jsonSerializer.stringify(buildCodexModelCatalog(ctx.models)) + contents: jsonSerializer.stringify(buildCodexModelCatalog(ctx.models, ctx.inferenceBaseUrl)) } ], message: "Launch Codex CLI with Zro" @@ -92,7 +95,12 @@ export const codexAppTool: ToolModule = { files: [ { path: path.join(codexHome, "config.toml"), - contents: buildCodexConfig(ctx.model, { catalogPath }), + contents: buildCodexConfig(ctx.model, { + catalogPath, + baseUrl: ctx.inferenceBaseUrl, + mcpUrl: ctx.mcpUrl, + noAuth: ctx.noAuth + }), persistent: true }, { @@ -102,7 +110,7 @@ export const codexAppTool: ToolModule = { }, { path: catalogPath, - contents: jsonSerializer.stringify(buildCodexModelCatalog(ctx.models)), + contents: jsonSerializer.stringify(buildCodexModelCatalog(ctx.models, ctx.inferenceBaseUrl)), persistent: true } ], @@ -119,12 +127,15 @@ export function codexAppCredentialFilePath(homeDir: string): string { return path.join(codexAppHome(homeDir), ".env"); } -export function buildCodexModelCatalog(modelSpecs: readonly ZroModel[]): Record { +export function buildCodexModelCatalog( + modelSpecs: readonly ZroModel[], + baseUrl = BASE_URL, +): Record { return { models: modelSpecs.map((model, index) => ({ slug: model.id, display_name: model.displayName, - description: `${PROVIDER_NAME} model via ${BASE_URL}`, + description: `${PROVIDER_NAME} model via ${baseUrl}`, default_reasoning_level: codexReasoningEffort( model.reasoning.levels.find( (level) => level.id === model.reasoning.defaultLevel @@ -160,13 +171,24 @@ export function buildCodexModelCatalog(modelSpecs: readonly ZroModel[]): Record< max_context_window: model.contextWindow, effective_context_window_percent: 95, experimental_supported_tools: [], - input_modalities: model.modalities.input, + input_modalities: codexInputModalities(model.modalities.input), supports_search_tool: false, use_responses_lite: false })) }; } +const CODEX_INPUT_MODALITIES = new Set(["text", "image", "audio"]); + +// Codex only accepts text/image/audio input modalities. The Zro catalog also +// lists video and pdf, so drop those before writing model_catalog_json. +function codexInputModalities( + input: ZroModel["modalities"]["input"] +): string[] { + const supported = input.filter((modality) => CODEX_INPUT_MODALITIES.has(modality)); + return supported.length > 0 ? supported : ["text"]; +} + // The remote catalog does not carry codexEffort, and parseModelCatalog // forwards whatever effort string a server sends. Undocumented values // ("max", "auto", "none") must not reach config.toml: accept only the @@ -196,7 +218,13 @@ function codexReasoningEffort( export function buildCodexConfig( model: string, - options: { catalogPath?: string; modelSpec?: ZroModel } = {} + options: { + catalogPath?: string; + modelSpec?: ZroModel; + baseUrl?: string; + mcpUrl?: string; + noAuth?: boolean; + } = {} ): string { const config = [ `model = ${tomlString(model)}`, @@ -224,14 +252,16 @@ export function buildCodexConfig( "", `[model_providers.${PROVIDER_ID}]`, `name = ${tomlString(PROVIDER_NAME)}`, - `base_url = ${tomlString(BASE_URL)}`, + `base_url = ${tomlString(options.baseUrl ?? BASE_URL)}`, `wire_api = "responses"`, `env_key = ${tomlString(ZRO_ENV_KEY)}`, `supports_websockets = false`, - "", - `[mcp_servers.${PROVIDER_ID}]`, - `url = ${tomlString(MCP_URL)}`, - `bearer_token_env_var = ${tomlString(ZRO_ENV_KEY)}`, + ...(options.noAuth ? [] : [ + "", + `[mcp_servers.${PROVIDER_ID}]`, + `url = ${tomlString(options.mcpUrl ?? MCP_URL)}`, + `bearer_token_env_var = ${tomlString(ZRO_ENV_KEY)}`, + ]), "", "[otel]", `exporter = "none"`, diff --git a/src/engine/tools/grok.ts b/src/engine/tools/grok.ts index 7ed668d..2f43d05 100644 --- a/src/engine/tools/grok.ts +++ b/src/engine/tools/grok.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { BASE_URL, MCP_URL, PROVIDER_ID, PROVIDER_NAME, ZRO_ENV_KEY, type ZroModel } from "../constants.js"; +import { PROVIDER_ID, PROVIDER_NAME, ZRO_ENV_KEY, type ZroModel } from "../constants.js"; import type { ToolModule } from "../types.js"; const GROK_HOME_ENV_KEY = "GROK_HOME"; @@ -22,7 +22,7 @@ export const grokTool: ToolModule = { }, files: [{ path: configPath, - contents: buildGrokConfig(ctx.model, ctx.apiKey, ctx.models) + contents: buildGrokConfig(ctx.model, ctx.apiKey, ctx.models, ctx.inferenceBaseUrl, ctx.mcpUrl, ctx.noAuth) }], message: "Launch Grok Build with Zro" }; @@ -33,6 +33,9 @@ function buildGrokConfig( selectedModel: string, apiKey: string, modelSpecs: readonly ZroModel[], + baseUrl: string, + mcpUrl: string, + noAuth: boolean, ): string { const lines: string[] = []; @@ -51,9 +54,9 @@ function buildGrokConfig( for (const model of modelSpecs) { lines.push(`[model.${tomlKey(model.id)}]`); lines.push(`model = ${tomlString(model.id)}`); - lines.push(`base_url = ${tomlString(BASE_URL)}`); + lines.push(`base_url = ${tomlString(baseUrl)}`); lines.push(`name = ${tomlString(`${PROVIDER_NAME} ${model.displayName}`)}`); - lines.push(`description = ${tomlString(`${PROVIDER_NAME} model via ${BASE_URL}`)}`); + lines.push(`description = ${tomlString(`${PROVIDER_NAME} model via ${baseUrl}`)}`); lines.push(`api_backend = "chat_completions"`); lines.push(`env_key = ${tomlString(ZRO_ENV_KEY)}`); lines.push(`context_window = ${model.contextWindow}`); @@ -62,10 +65,12 @@ function buildGrokConfig( lines.push(""); } - lines.push(`[mcp_servers.${PROVIDER_ID}]`); - lines.push(`url = ${tomlString(MCP_URL)}`); - lines.push(`headers = { "Authorization" = ${tomlString(`Bearer ${apiKey}`)} }`); - lines.push(""); + if (!noAuth) { + lines.push(`[mcp_servers.${PROVIDER_ID}]`); + lines.push(`url = ${tomlString(mcpUrl)}`); + lines.push(`headers = { "Authorization" = ${tomlString(`Bearer ${apiKey}`)} }`); + lines.push(""); + } return lines.join("\n"); } diff --git a/src/engine/tools/hermes.ts b/src/engine/tools/hermes.ts index 13a1921..2cf133e 100644 --- a/src/engine/tools/hermes.ts +++ b/src/engine/tools/hermes.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { BASE_URL, MCP_URL, PROVIDER_ID, ZRO_ENV_KEY, type ZroModel } from "../constants.js"; +import { PROVIDER_ID, ZRO_ENV_KEY, type ZroModel } from "../constants.js"; import { readConfig } from "../files.js"; import { yamlSerializer } from "../serializers.js"; import type { ApiKeySource, ToolModule } from "../types.js"; @@ -12,7 +12,16 @@ export const hermesTool: ToolModule = { const tempHome = path.join(ctx.tempDir, "home"); const filePath = path.join(tempHome, ".hermes", "config.yaml"); const existing = await readConfig(path.join(ctx.homeDir, ".hermes", "config.yaml"), yamlSerializer); - const nextConfig = buildHermesConfig(existing, ctx.apiKey, ctx.apiKeySource, ctx.models, ctx.model); + const nextConfig = buildHermesConfig( + existing, + ctx.apiKey, + ctx.apiKeySource, + ctx.models, + ctx.model, + ctx.inferenceBaseUrl, + ctx.mcpUrl, + ctx.noAuth, + ); return { tool: "hermes", label: "Hermes", @@ -41,7 +50,10 @@ function buildHermesConfig( apiKey: string, apiKeySource: ApiKeySource, modelSpecs: readonly ZroModel[], - selectedModel: string + selectedModel: string, + baseUrl: string, + mcpUrl: string, + noAuth: boolean, ): Record { const next = { ...existing }; const providers = Array.isArray(next.custom_providers) ? [...next.custom_providers] : []; @@ -58,7 +70,7 @@ function buildHermesConfig( const provider: Record = { ...existingProvider, name: PROVIDER_ID, - base_url: BASE_URL, + base_url: baseUrl, api_mode: "chat_completions", models }; @@ -78,23 +90,29 @@ function buildHermesConfig( } const mcpServers = { ...(asPlainObject(next.mcp_servers) ?? {}) }; - const existingMcpServer = asPlainObject(mcpServers[PROVIDER_ID]) ?? {}; - mcpServers[PROVIDER_ID] = { - ...existingMcpServer, - url: MCP_URL, - enabled: true, - headers: { - ...(asPlainObject(existingMcpServer.headers) ?? {}), - Authorization: `Bearer \${${ZRO_ENV_KEY}}` - }, - tools: { - ...(asPlainObject(existingMcpServer.tools) ?? {}), - include: ["zro-web_search"], - resources: false, - prompts: false - } - }; - next.mcp_servers = mcpServers; + if (noAuth) { + delete mcpServers[PROVIDER_ID]; + if (Object.keys(mcpServers).length) next.mcp_servers = mcpServers; + else delete next.mcp_servers; + } else { + const existingMcpServer = asPlainObject(mcpServers[PROVIDER_ID]) ?? {}; + mcpServers[PROVIDER_ID] = { + ...existingMcpServer, + url: mcpUrl, + enabled: true, + headers: { + ...(asPlainObject(existingMcpServer.headers) ?? {}), + Authorization: `Bearer \${${ZRO_ENV_KEY}}` + }, + tools: { + ...(asPlainObject(existingMcpServer.tools) ?? {}), + include: ["zro-web_search"], + resources: false, + prompts: false + } + }; + next.mcp_servers = mcpServers; + } const existingModelConfig = asPlainObject(next.model) ?? {}; // Hermes' per-launch output cap is top-level model.max_tokens (provider diff --git a/src/engine/tools/kilo.ts b/src/engine/tools/kilo.ts index 839d147..ef70187 100644 --- a/src/engine/tools/kilo.ts +++ b/src/engine/tools/kilo.ts @@ -101,7 +101,7 @@ export const kiloTool: ToolModule = { const userKiloRoot = path.join(userConfigHome, "kilo"); const existing = await readKiloConfig(userKiloRoot); const safeConfig = sanitizeKiloConfig(existing); - const overlay = buildKiloConfigOverlay(ctx.model, ctx.models); + const overlay = buildKiloConfigOverlay(ctx.model, ctx.models, ctx.inferenceBaseUrl, ctx.mcpUrl, ctx.noAuth); const userFiles = await collectSafeUserAssets(userKiloRoot, kiloConfigRoot); return { @@ -149,6 +149,9 @@ export const kiloTool: ToolModule = { export function buildKiloConfigOverlay( model: string, modelSpecs: readonly ZroModel[], + baseUrl = BASE_URL, + mcpUrl = MCP_URL, + noAuth = false, ): Record { const models = Object.fromEntries(modelSpecs.map((spec) => [ spec.id, @@ -175,23 +178,25 @@ export function buildKiloConfigOverlay( npm: "@ai-sdk/openai-compatible", name: PROVIDER_NAME, options: { - baseURL: BASE_URL, + baseURL: baseUrl, apiKey: `{env:${ZRO_ENV_KEY}}`, }, models, }, }, - mcp: { - [PROVIDER_ID]: { - type: "remote", - url: MCP_URL, - headers: { - Authorization: `Bearer {env:${ZRO_ENV_KEY}}`, + ...(noAuth ? {} : { + mcp: { + [PROVIDER_ID]: { + type: "remote", + url: mcpUrl, + headers: { + Authorization: `Bearer {env:${ZRO_ENV_KEY}}`, + }, + enabled: true, + oauth: false, }, - enabled: true, - oauth: false, }, - }, + }), experimental: { openTelemetry: false, }, diff --git a/src/engine/tools/omp.ts b/src/engine/tools/omp.ts index 7490ac5..bff6626 100644 --- a/src/engine/tools/omp.ts +++ b/src/engine/tools/omp.ts @@ -3,7 +3,6 @@ import type { Dirent, Stats } from "node:fs"; import path from "node:path"; import { BASE_URL, - MCP_URL, PROVIDER_ID, ZRO_ENV_KEY, type ZroModel, @@ -126,7 +125,7 @@ export const ompTool: ToolModule = { OTEL_EXPORTER_OTLP_HEADERS: "", DO_NOT_TRACK: "1", NO_UPDATE_NOTIFIER: "1", - [OMP_MCP_AUTH_ENV_KEY]: `Bearer ${ctx.apiKey}`, + ...(ctx.noAuth ? {} : { [OMP_MCP_AUTH_ENV_KEY]: `Bearer ${ctx.apiKey}` }), [ZRO_ENV_KEY]: ctx.apiKey, }, files: [ @@ -140,12 +139,12 @@ export const ompTool: ToolModule = { }, { path: path.join(agentDir, "models.yml"), - contents: yamlSerializer.stringify(buildOmpModelsConfig(ctx.models)), + contents: yamlSerializer.stringify(buildOmpModelsConfig(ctx.models, ctx.inferenceBaseUrl)), }, - { + ...(ctx.noAuth ? [] : [{ path: path.join(agentDir, "mcp.json"), - contents: jsonSerializer.stringify(buildOmpMcpConfig()), - }, + contents: jsonSerializer.stringify(buildOmpMcpConfig(ctx.mcpUrl)), + }]), ...userFiles, ], message: "Launch Oh My Pi with Zro", @@ -184,11 +183,12 @@ export function buildOmpConfigOverlay( export function buildOmpModelsConfig( modelSpecs: readonly ZroModel[], + baseUrl = BASE_URL, ): Record { return { providers: { [PROVIDER_ID]: { - baseUrl: BASE_URL, + baseUrl: baseUrl, apiKey: ZRO_ENV_KEY, api: "openai-completions", compat: { @@ -268,13 +268,13 @@ function ompThinkingLevel(level: ZroModel["reasoning"]["levels"][number]): strin return OMP_THINKING_LEVELS.has(level.id) ? level.id : level.piLevel; } -function buildOmpMcpConfig(): Record { +function buildOmpMcpConfig(mcpUrl: string): Record { return { $schema: OMP_MCP_SCHEMA, mcpServers: { [PROVIDER_ID]: { type: "http", - url: MCP_URL, + url: mcpUrl, headers: { Authorization: OMP_MCP_AUTH_ENV_KEY }, enabled: true, }, diff --git a/src/engine/tools/openclaw.ts b/src/engine/tools/openclaw.ts index ca7b72b..eaccdc1 100644 --- a/src/engine/tools/openclaw.ts +++ b/src/engine/tools/openclaw.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { BASE_URL, MCP_URL, PROVIDER_ID, ZRO_ENV_KEY, type ZroModel } from "../constants.js"; +import { PROVIDER_ID, ZRO_ENV_KEY, type ZroModel } from "../constants.js"; import { readConfig } from "../files.js"; import { json5Serializer } from "../serializers.js"; import type { ToolModule } from "../types.js"; @@ -14,7 +14,7 @@ export const openClawTool: ToolModule = { const tempHome = path.join(ctx.tempDir, "home"); const filePath = path.join(tempHome, ".openclaw", "openclaw.json"); const existing = await readConfig(path.join(ctx.homeDir, ".openclaw", "openclaw.json"), json5Serializer); - const nextConfig = buildOpenClawConfig(existing, ctx.apiKey, ctx.models); + const nextConfig = buildOpenClawConfig(existing, ctx.apiKey, ctx.models, ctx.inferenceBaseUrl, ctx.mcpUrl, ctx.noAuth); objectAt(objectAt(objectAt(nextConfig, "agents"), "defaults"), "model").primary = `${PROVIDER_ID}/${ctx.model}`; return { tool: "openclaw", @@ -51,7 +51,10 @@ function buildOpenClawArgs(extraArgs: string[]): string[] { function buildOpenClawConfig( existing: Record, apiKey: string, - modelSpecs: readonly ZroModel[] + modelSpecs: readonly ZroModel[], + baseUrl: string, + mcpUrl: string, + noAuth: boolean, ): Record { const next = { ...existing }; @@ -83,28 +86,34 @@ function buildOpenClawConfig( providers[PROVIDER_ID] = { ...provider, - baseUrl: BASE_URL, + baseUrl: baseUrl, apiKey, api: OPENCLAW_API, timeoutSeconds: typeof provider.timeoutSeconds === "number" ? provider.timeoutSeconds : 300, models: providerModels }; - const mcpServers = objectAt(objectAt(next, "mcp"), "servers"); - const existingMcpServer = asPlainObject(mcpServers[PROVIDER_ID]) ?? {}; - mcpServers[PROVIDER_ID] = { - ...existingMcpServer, - url: MCP_URL, - transport: "streamable-http", - enabled: true, - headers: { - ...(asPlainObject(existingMcpServer.headers) ?? {}), - Authorization: `Bearer \${${ZRO_ENV_KEY}}` - }, - toolFilter: { - include: ["zro-web_search"] - } - }; + if (noAuth) { + const mcp = asPlainObject(next.mcp); + const mcpServers = asPlainObject(mcp?.servers); + if (mcpServers) delete mcpServers[PROVIDER_ID]; + } else { + const mcpServers = objectAt(objectAt(next, "mcp"), "servers"); + const existingMcpServer = asPlainObject(mcpServers[PROVIDER_ID]) ?? {}; + mcpServers[PROVIDER_ID] = { + ...existingMcpServer, + url: mcpUrl, + transport: "streamable-http", + enabled: true, + headers: { + ...(asPlainObject(existingMcpServer.headers) ?? {}), + Authorization: `Bearer \${${ZRO_ENV_KEY}}` + }, + toolFilter: { + include: ["zro-web_search"] + } + }; + } return next; } diff --git a/src/engine/tools/opencode.ts b/src/engine/tools/opencode.ts index b9d348d..60c50e3 100644 --- a/src/engine/tools/opencode.ts +++ b/src/engine/tools/opencode.ts @@ -19,7 +19,11 @@ export const opencodeTool: ToolModule = { const userConfigRoot = ctx.env.XDG_CONFIG_HOME ?? path.join(ctx.homeDir, ".config"); const userOpencodeRoot = path.join(userConfigRoot, "opencode"); const existing = await readOpenCodeConfig(userOpencodeRoot); - const nextConfig = buildOpenCodeConfig(existing, ctx.apiKey, ctx.models, true); + const nextConfig = buildOpenCodeConfig(existing, ctx.apiKey, ctx.models, true, { + baseUrl: ctx.inferenceBaseUrl, + mcpUrl: ctx.mcpUrl, + noAuth: ctx.noAuth, + }); nextConfig.model = `${PROVIDER_ID}/${ctx.model}`; const userFiles = await collectUserConfigFiles(userOpencodeRoot, opencodeRoot); @@ -153,7 +157,8 @@ export function buildOpenCodeConfig( existing: Record, apiKey: string, modelSpecs: readonly ZroModel[], - useEnvReference = false + useEnvReference = false, + endpoints: { baseUrl?: string; mcpUrl?: string; noAuth?: boolean } = {}, ): Record { const next = { ...existing }; if (typeof next.$schema !== "string") { @@ -165,18 +170,24 @@ export function buildOpenCodeConfig( provider[PROVIDER_ID], apiKey, modelSpecs, - useEnvReference + useEnvReference, + endpoints.baseUrl ); - const mcp = objectAt(next, "mcp"); - mcp[PROVIDER_ID] = { - type: "remote", - url: MCP_URL, - enabled: true, - oauth: false, - headers: { - Authorization: `Bearer {env:${ZRO_ENV_KEY}}` - } - }; + if (endpoints.noAuth) { + const mcp = asPlainObject(next.mcp); + if (mcp) delete mcp[PROVIDER_ID]; + } else { + const mcp = objectAt(next, "mcp"); + mcp[PROVIDER_ID] = { + type: "remote", + url: endpoints.mcpUrl ?? MCP_URL, + enabled: true, + oauth: false, + headers: { + Authorization: `Bearer {env:${ZRO_ENV_KEY}}` + } + }; + } return next; } @@ -184,7 +195,8 @@ function openAiCompatibleProvider( existing: unknown, apiKey: string, modelSpecs: readonly ZroModel[], - useEnvReference = false + useEnvReference = false, + baseUrl = BASE_URL, ): Record { const existingProvider = asPlainObject(existing) ?? {}; const existingOptions = asPlainObject(existingProvider.options) ?? {}; @@ -218,7 +230,7 @@ function openAiCompatibleProvider( name: PROVIDER_NAME, options: { ...existingOptions, - baseURL: BASE_URL, + baseURL: baseUrl, apiKey: useEnvReference ? `{env:${ZRO_ENV_KEY}}` : apiKey }, models diff --git a/src/engine/tools/pi.ts b/src/engine/tools/pi.ts index 42be147..ee38aac 100644 --- a/src/engine/tools/pi.ts +++ b/src/engine/tools/pi.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { BASE_URL, MCP_URL, PROVIDER_ID, PROVIDER_NAME, ZRO_ENV_KEY, type ZroModel } from "../constants.js"; +import { BASE_URL, PROVIDER_ID, PROVIDER_NAME, ZRO_ENV_KEY, type ZroModel } from "../constants.js"; import { readConfig } from "../files.js"; import { jsonSerializer } from "../serializers.js"; import type { ToolModule } from "../types.js"; @@ -19,7 +19,7 @@ export const piTool: ToolModule = { const mcpConfigPath = path.join(tempAgentDir, "mcp.json"); const existingAgentDir = process.env[PI_AGENT_DIR_ENV_KEY] ?? path.join(ctx.homeDir, ".pi", "agent"); const existing = await readConfig(path.join(existingAgentDir, "models.json"), jsonSerializer); - const nextConfig = buildPiModelsConfig(existing, ctx.models); + const nextConfig = buildPiModelsConfig(existing, ctx.models, ctx.inferenceBaseUrl); return { tool: "pi", label: "Pi", @@ -28,7 +28,7 @@ export const piTool: ToolModule = { args: [ "--provider", PROVIDER_ID, "--model", ctx.model, - "--extension", PI_MCP_EXTENSION, + ...(ctx.noAuth ? [] : ["--extension", PI_MCP_EXTENSION]), ...ctx.extraArgs ], env: { @@ -41,19 +41,19 @@ export const piTool: ToolModule = { path: filePath, contents: jsonSerializer.stringify(nextConfig) }, - { + ...(ctx.noAuth ? [] : [{ path: mcpConfigPath, contents: jsonSerializer.stringify({ mcpServers: { zro: { transport: "streamable-http", - url: MCP_URL, + url: ctx.mcpUrl, lifecycle: "eager", headers: { Authorization: `Bearer ${ctx.apiKey}` } } } }) - } + }]) ], message: "Launch Pi with Zro" }; @@ -62,7 +62,8 @@ export const piTool: ToolModule = { function buildPiModelsConfig( existing: Record, - modelSpecs: readonly ZroModel[] + modelSpecs: readonly ZroModel[], + baseUrl = BASE_URL, ): Record { const next = { ...existing }; const providers = objectAt(next, "providers"); @@ -74,7 +75,7 @@ function buildPiModelsConfig( providers[PROVIDER_ID] = { ...existingProvider, - baseUrl: BASE_URL, + baseUrl: baseUrl, api: "openai-completions", apiKey: `$${ZRO_ENV_KEY}`, headers: { diff --git a/src/engine/tools/prime.ts b/src/engine/tools/prime.ts index 10d1a65..5352e69 100644 --- a/src/engine/tools/prime.ts +++ b/src/engine/tools/prime.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { BASE_URL, MCP_URL, PROVIDER_ID, PROVIDER_NAME, ZRO_ENV_KEY, type ZroModel } from "../constants.js"; +import { BASE_URL, PROVIDER_ID, PROVIDER_NAME, ZRO_ENV_KEY, type ZroModel } from "../constants.js"; import { readConfig } from "../files.js"; import { jsonSerializer } from "../serializers.js"; import type { ToolModule } from "../types.js"; @@ -30,7 +30,7 @@ export const primeTool: ToolModule = { const filePath = path.join(tempAgentDir, "models.json"); const settingsPath = path.join(tempAgentDir, "settings.json"); const existing = await readConfig(path.join(existingAgentDir, "models.json"), jsonSerializer); - const nextConfig = buildPrimeModelsConfig(existing, ctx.models); + const nextConfig = buildPrimeModelsConfig(existing, ctx.models, ctx.inferenceBaseUrl); return { tool: "prime", label: "Prime Agent", @@ -47,18 +47,18 @@ export const primeTool: ToolModule = { path: filePath, contents: jsonSerializer.stringify(nextConfig) }, - { + ...(ctx.noAuth ? [] : [{ path: settingsPath, contents: jsonSerializer.stringify({ mcpServers: { zro: { type: "http", - url: MCP_URL, + url: ctx.mcpUrl, bearer_token_env_var: ZRO_ENV_KEY } } }) - } + }]) ], message: "Launch Prime Agent with Zro" }; @@ -67,7 +67,8 @@ export const primeTool: ToolModule = { function buildPrimeModelsConfig( existing: Record, - modelSpecs: readonly ZroModel[] + modelSpecs: readonly ZroModel[], + baseUrl = BASE_URL, ): Record { const next = { ...existing }; const providers = objectAt(next, "providers"); @@ -79,7 +80,7 @@ function buildPrimeModelsConfig( providers[PROVIDER_ID] = { ...existingProvider, - baseUrl: BASE_URL, + baseUrl: baseUrl, api: "openai-completions", apiKey: ZRO_ENV_KEY, headers: { diff --git a/src/engine/types.ts b/src/engine/types.ts index c2955b6..96069c9 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -4,11 +4,15 @@ import type { SUPPORTED_TOOLS } from "./constants.js"; import type { ZroModel } from "./constants.js"; export type ToolId = (typeof SUPPORTED_TOOLS)[number]; -export type ApiKeySource = "flag" | "env" | "stored"; +export type ApiKeySource = "flag" | "env" | "stored" | "none"; export interface LaunchContext { apiKey: string; apiKeySource: ApiKeySource; + inferenceRoot: string; + inferenceBaseUrl: string; + mcpUrl: string; + noAuth: boolean; env: NodeJS.ProcessEnv; model: string; models: readonly ZroModel[]; diff --git a/src/model-catalog.ts b/src/model-catalog.ts index d3d5e7a..f9f4931 100644 --- a/src/model-catalog.ts +++ b/src/model-catalog.ts @@ -1,11 +1,11 @@ import fs from "node:fs/promises"; import path from "node:path"; import { - ENDPOINT_ROOT, type ZroModel, type ZroModality, type ZroModalities, } from "./engine/constants.js"; +import { resolveControlPlaneRoot } from "./endpoints.js"; export interface ModelCatalog { version: 1; @@ -22,6 +22,8 @@ export interface LoadedModelCatalog extends ModelCatalog { type CatalogOptions = { apiKey?: string; cacheRemote?: boolean; + noAuth?: boolean; + inferenceRoot?: string; env: NodeJS.ProcessEnv; homeDir: string; fetch?: (input: string | URL | Request, init?: RequestInit) => Promise; @@ -42,6 +44,8 @@ export class CatalogUnavailableError extends Error { } export async function loadModelCatalog(options: CatalogOptions): Promise { + if (options.noAuth) return loadPublicCatalog(options, options.inferenceRoot); + let fetchFailure: unknown; if (options.apiKey) { try { @@ -84,6 +88,11 @@ export function modelCatalogCachePath(options: Pick): string { + const cacheRoot = options.env.XDG_CACHE_HOME || path.join(options.homeDir, ".cache"); + return path.join(cacheRoot, "zro", "model-catalog-public.json"); +} + async function fetchAuthenticatedCatalog(options: CatalogOptions, apiKey: string): Promise { const fetcher = options.fetch ?? globalThis.fetch; const endpointRoot = catalogRoot(options.env); @@ -105,7 +114,105 @@ async function fetchAuthenticatedCatalog(options: CatalogOptions, apiKey: string } function catalogRoot(env: NodeJS.ProcessEnv): string { - return (env.ZRO_AUTH_URL || env.ZRO_ENDPOINT_ROOT || ENDPOINT_ROOT).replace(/\/+$/, ""); + return resolveControlPlaneRoot(env); +} + +async function loadPublicCatalog( + options: CatalogOptions, + inferenceRoot: string | undefined, +): Promise { + if (!inferenceRoot) { + throw new Error("No-auth mode needs an inference URL. Pass --inference-url or set ZRO_INFERENCE_URL."); + } + + let source: CatalogSource = "remote"; + let catalog: ModelCatalog | null = null; + let fetchFailure: unknown; + try { + catalog = await fetchPublicCatalog(options); + if (options.cacheRemote !== false) { + await writePublicCachedCatalog(options, catalog).catch(() => {}); + } + } catch (error) { + fetchFailure = error; + } + + if (!catalog) { + catalog = await readPublicCachedCatalog(options); + source = "cache"; + } + if (!catalog) { + throw new CatalogUnavailableError( + fetchFailure instanceof Error ? fetchFailure.message : String(fetchFailure ?? "no cached catalog"), + ); + } + + const activeIds = await fetchActiveModelIds(options, inferenceRoot); + return { ...intersectCatalog(catalog, activeIds), source }; +} + +async function fetchPublicCatalog(options: CatalogOptions): Promise { + const fetcher = options.fetch ?? globalThis.fetch; + const response = await fetcher(`${catalogRoot(options.env)}/api/cli/models/public`, { + signal: AbortSignal.timeout(5_000), + }); + if (!response.ok) { + await response.body?.cancel().catch(() => {}); + throw new Error(`Public model catalog request failed with HTTP ${response.status}.`); + } + return parseModelCatalog(await response.json()); +} + +async function fetchActiveModelIds(options: CatalogOptions, inferenceRoot: string): Promise> { + const fetcher = options.fetch ?? globalThis.fetch; + const root = inferenceRoot.replace(/\/+$/, ""); + const response = await fetcher(`${root}/v1/models`, { + signal: AbortSignal.timeout(5_000), + }); + if (!response.ok) { + await response.body?.cancel().catch(() => {}); + throw new Error(`Inference gateway model list request failed with HTTP ${response.status}.`); + } + return parseActiveModelIds(await response.json()); +} + +function parseActiveModelIds(value: unknown): Set { + const data = asRecord(value, "inference gateway model list").data; + if (!Array.isArray(data)) throw new Error("Invalid inference gateway model list."); + const ids = new Set(); + for (const entry of data) { + const id = asRecord(entry, "inference gateway model").id; + if (typeof id === "string" && id.trim()) ids.add(id.replace(/^zro\//, "")); + } + return ids; +} + +function intersectCatalog(catalog: ModelCatalog, activeIds: Set): ModelCatalog { + const models = catalog.models.filter((model) => activeIds.has(model.id)); + if (models.length === 0) { + throw new Error("The inference gateway does not offer any public models."); + } + const defaultModel = models.some((model) => model.id === catalog.default) + ? catalog.default + : models[0].id; + return { version: 1, default: defaultModel, models }; +} + +async function readPublicCachedCatalog(options: CatalogOptions): Promise { + try { + const contents = await fs.readFile(publicModelCatalogCachePath(options), "utf8"); + return parseModelCatalog(JSON.parse(contents)); + } catch { + return null; + } +} + +async function writePublicCachedCatalog(options: CatalogOptions, catalog: ModelCatalog): Promise { + const filePath = publicModelCatalogCachePath(options); + await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 }); + const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + await fs.writeFile(temporaryPath, `${JSON.stringify(catalog, null, 2)}\n`, { mode: 0o600 }); + await fs.rename(temporaryPath, filePath); } async function readCachedCatalog(options: CatalogOptions): Promise { diff --git a/src/run.ts b/src/run.ts index 42d9c5b..a6591b2 100644 --- a/src/run.ts +++ b/src/run.ts @@ -8,7 +8,7 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { ENDPOINT_ROOT, ZRO_ENV_KEY, type ZroModel } from "./engine/constants.js"; +import { NO_AUTH_PLACEHOLDER_KEY, ZRO_ENV_KEY, type ZroModel } from "./engine/constants.js"; import { credentialFilePath, deleteStoredApiKey, @@ -34,6 +34,7 @@ import { piTool } from "./engine/tools/pi.js"; import { primeTool } from "./engine/tools/prime.js"; import type { LaunchPlan, SpawnProcess, ToolId, ToolModule } from "./engine/types.js"; import { parseArgs } from "./args.js"; +import { isNoAuth, resolveControlPlaneRoot, resolveInference, resolveMcpUrl } from "./endpoints.js"; import { describeTool } from "./catalog.js"; import { commandExists, ensureHarnessInstalled, install } from "./install.js"; import { readPreferences, writePreferences } from "./preferences.js"; @@ -91,8 +92,8 @@ export async function run(argv: string[], io: RunIo = defaultIo()): Promise ): Promise { - let key; + const noAuth = isNoAuth(request.noAuth, env); + let inference: ReturnType; + let mcpUrl: string; try { - key = await resolveApiKey({ flagValue: request.apiKey, env, homeDir: io.homeDir }); - } catch { - if (!isTty(io.stdin) || !isTty(io.stdout)) { - io.stderr.write("Not logged in. Run zro login or set ZRO_API_KEY.\n"); - return 1; + inference = resolveInference(env, request.inferenceUrl, noAuth); + mcpUrl = resolveMcpUrl(env); + } catch (error) { + io.stderr.write(`${messageOf(error)}\n`); + return 1; + } + + let key; + if (noAuth) { + key = { apiKey: NO_AUTH_PLACEHOLDER_KEY, source: "none" as const }; + } else { + try { + key = await resolveApiKey({ flagValue: request.apiKey, env, homeDir: io.homeDir }); + } catch { + if (!isTty(io.stdin) || !isTty(io.stdout)) { + io.stderr.write("Not logged in. Run zro login or set ZRO_API_KEY.\n"); + return 1; + } + const connected = await connectInteractively(io, env, colors); + if (typeof connected === "number") return connected; + key = connected; } - const connected = await connectInteractively(io, env, colors); - if (typeof connected === "number") return connected; - key = connected; } - if (request.apiKey) { + if (request.apiKey && !noAuth) { io.stderr.write(`Note: --api-key can land in shell history. Prefer zro login or ${ZRO_ENV_KEY}.\n`); } @@ -163,6 +181,8 @@ async function launch( try { catalog = await loadModelCatalog({ apiKey: key.apiKey, + noAuth, + inferenceRoot: inference.root, env, homeDir: io.homeDir, fetch: io.fetch, @@ -231,6 +251,10 @@ async function launch( plan = await tools[request.tool].launch({ apiKey: key.apiKey, apiKeySource: key.source, + inferenceRoot: inference.root, + inferenceBaseUrl: inference.baseUrl, + mcpUrl, + noAuth, env, model, models: catalog.models, @@ -256,7 +280,7 @@ async function launch( return 0; } - if (!await verifyApiKey(key.apiKey, io, env)) { + if (!noAuth && !await verifyApiKey(key.apiKey, io, env, inference.root)) { await fs.rm(tempDir, { recursive: true, force: true }); return 1; } @@ -310,10 +334,10 @@ async function verifyApiKey( apiKey: string, io: RunIo, env: NodeJS.ProcessEnv, + inferenceRoot: string, ): Promise { const fetcher = io.fetch ?? globalThis.fetch; - const endpointRoot = (env.ZRO_ENDPOINT_ROOT || ENDPOINT_ROOT).replace(/\/+$/, ""); - const validationUrl = `${endpointRoot}/v1/models`; + const validationUrl = `${inferenceRoot}/v1/models`; let response: Response; try { @@ -323,7 +347,7 @@ async function verifyApiKey( }); } catch (error) { io.stderr.write( - `Could not verify Zro authentication at ${endpointRoot}: ${messageOf(error)}. Agent was not started.\n`, + `Could not verify Zro authentication at ${inferenceRoot}: ${messageOf(error)}. Agent was not started.\n`, ); return false; } @@ -596,16 +620,49 @@ async function deleteOptionalFile(filePath: string): Promise { } async function status( - output: "human" | "json", + request: Extract, io: RunIo, env: NodeJS.ProcessEnv, colors: ReturnType ): Promise { + const output = request.output; + const preferences = await readPreferences({ homeDir: io.homeDir, env }); + if (isNoAuth(request.noAuth, env)) { + let inferenceRoot: string | undefined; + try { + inferenceRoot = resolveInference(env, request.inferenceUrl).root; + } catch { + inferenceRoot = undefined; + } + if (output === "json") { + io.stdout.write(`${JSON.stringify({ + connected: false, + mode: "no-auth", + noAuth: true, + credentialSource: "none", + inferenceUrl: inferenceRoot ?? null, + lastSession: preferences.lastTool && preferences.lastModel + ? { tool: preferences.lastTool, model: preferences.lastModel } + : null, + }, null, 2)}\n`); + return 0; + } + + io.stdout.write(`${banner(colors)}\n`); + io.stdout.write(`\n${colors.muted("CONNECTION")}\n`); + io.stdout.write(` ${colors.good("◆")} ${colors.strong("No-auth gateway mode")}\n`); + if (inferenceRoot) { + io.stdout.write(` ${colors.muted(inferenceRoot)}\n`); + } + io.stdout.write(`\n${colors.muted("LAST SESSION")}\n`); + printLastSession(preferences, io, colors); + return 0; + } + const stored = await readStoredApiKey({ homeDir: io.homeDir, env }); const envKey = env[ZRO_ENV_KEY]; const source = envKey ? "environment" : stored ? "stored" : "none"; const credential = envKey ?? stored; - const preferences = await readPreferences({ homeDir: io.homeDir, env }); const accountResult = credential ? await fetchAccountStatus(credential, io, env) : { state: "not_connected" } as const; @@ -648,6 +705,15 @@ async function status( } io.stdout.write(`\n${colors.muted("LAST SESSION")}\n`); + printLastSession(preferences, io, colors); + return 0; +} + +function printLastSession( + preferences: Awaited>, + io: RunIo, + colors: ReturnType, +): void { if (preferences.lastTool && preferences.lastModel) { io.stdout.write( ` ${colors.accent("◆")} ${colors.strong(describeTool(preferences.lastTool).name)} ` + @@ -657,7 +723,6 @@ async function status( } else { io.stdout.write(` ${colors.muted("◇")} None yet — start with ${colors.muted("zro claude")}\n`); } - return 0; } type AccountStatus = { @@ -720,7 +785,7 @@ async function fetchAccountStatus( } function getAuthRoot(env: NodeJS.ProcessEnv): string { - return (env.ZRO_AUTH_URL || env.ZRO_ENDPOINT_ROOT || ENDPOINT_ROOT).replace(/\/+$/, ""); + return resolveControlPlaneRoot(env); } function browserApprovalUrl(serverUrl: string, env: NodeJS.ProcessEnv): string { @@ -810,26 +875,51 @@ function formatInteger(value: number): string { } async function models( - output: "human" | "json", + request: Extract, io: RunIo, env: NodeJS.ProcessEnv, colors: ReturnType, ): Promise { + const output = request.output; + const noAuth = isNoAuth(request.noAuth, env); + let inferenceRoot: string | undefined; + if (noAuth) { + try { + inferenceRoot = resolveInference(env, request.inferenceUrl, true).root; + } catch (error) { + io.stderr.write(`${messageOf(error)}\n`); + return 1; + } + } + let apiKey: string | undefined; - try { - apiKey = (await resolveApiKey({ env, homeDir: io.homeDir })).apiKey; - } catch { - // A cached catalog remains useful before the user signs in. + if (!noAuth) { + try { + apiKey = (await resolveApiKey({ env, homeDir: io.homeDir })).apiKey; + } catch { + // A cached catalog remains useful before the user signs in. + } } let catalog: LoadedModelCatalog; try { - catalog = await loadModelCatalog({ apiKey, env, homeDir: io.homeDir, fetch: io.fetch }); + catalog = await loadModelCatalog({ + apiKey, + noAuth, + inferenceRoot, + env, + homeDir: io.homeDir, + fetch: io.fetch, + }); } catch (error) { if (error instanceof CatalogAuthenticationError) { io.stderr.write(`Authentication failed: ${error.message} Run zro login --manual with a valid API key.\n`); return 1; } + if (noAuth) { + io.stderr.write(`Could not load the public model catalog: ${messageOf(error)}\n`); + return 1; + } if (output === "human" && isTty(io.stdin) && isTty(io.stdout)) { const connected = await connectInteractively(io, env, colors); if (typeof connected === "number") return connected; @@ -870,6 +960,11 @@ async function feedback( env: NodeJS.ProcessEnv, colors: ReturnType, ): Promise { + if (isNoAuth(request.noAuth, env)) { + io.stderr.write("Feedback needs a Zro account and is unavailable in no-auth mode.\n"); + return 1; + } + let message = request.message; if (!message) { if (isTty(io.stdin) && isTty(io.stdout)) { @@ -1007,7 +1102,7 @@ export function unknownModelMessage(model: string, source: CatalogSource): strin } export function redact(key: string, value: string, secret: string): string { - if (value === "") return value; + if (value === "" || value === NO_AUTH_PLACEHOLDER_KEY) return value; const nameTokens = key.split(/[^A-Za-z0-9]+/).map((token) => token.toUpperCase()); if (nameTokens.some((token) => SECRET_NAME_TOKENS.has(token)) || value.includes(secret)) { return maskKey(value); diff --git a/src/types.ts b/src/types.ts index 81aedf8..420b557 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,11 +17,24 @@ export type CliRequest = output: OutputMode; } | { command: "logout"; output: OutputMode } - | { command: "status"; output: OutputMode } - | { command: "models"; output: OutputMode } + | { command: "status"; output: OutputMode; inferenceUrl?: string; noAuth?: boolean } + | { command: "models"; output: OutputMode; inferenceUrl?: string; noAuth?: boolean } | { command: "install"; tool?: string; upgrade: boolean; version?: string } - | { command: "again"; dryRun: boolean; output: OutputMode; aliases?: Record } - | { command: "feedback"; message?: string; output: OutputMode } + | { + command: "again"; + dryRun: boolean; + output: OutputMode; + aliases?: Record; + inferenceUrl?: string; + noAuth?: boolean; + } + | { + command: "feedback"; + message?: string; + output: OutputMode; + inferenceUrl?: string; + noAuth?: boolean; + } | { command: "launch"; tool: ToolId; @@ -32,6 +45,8 @@ export type CliRequest = output: OutputMode; extraArgs: string[]; aliases?: Record; + inferenceUrl?: string; + noAuth?: boolean; }; export interface Preferences { diff --git a/src/ui.ts b/src/ui.ts index ee007f2..7487ff0 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -49,6 +49,14 @@ ${colors.strong("Make it yours")} zro feedback Send feedback to the Zro team zro claude --dry-run Preview without launching +${colors.strong("Inference endpoint")} + zro claude --inference-url https://gateway.example + Send inference to another OpenAI-compatible gateway + zro models --no-auth --inference-url https://gateway.example + Use a no-auth gateway with public model metadata + ZRO_INFERENCE_URL Default inference root (flag wins) + ZRO_NO_AUTH=1 Shorthand for --no-auth + ${colors.strong("Tools")} ${TOOLS.map((tool) => tool.id).join(" ")} diff --git a/tests/args.test.ts b/tests/args.test.ts index 5da41e2..4f606e5 100644 --- a/tests/args.test.ts +++ b/tests/args.test.ts @@ -155,3 +155,35 @@ describe("the direct command language", () => { }); }); }); + +describe("alternate inference endpoint arguments", () => { + it("parses inference URL overrides and no-auth mode", () => { + expect(parseArgs([ + "claude", + "--inference-url", + "https://gateway.example/v1/", + "--no-auth", + ])).toMatchObject({ + command: "launch", + tool: "claude", + inferenceUrl: "https://gateway.example", + noAuth: true, + }); + expect(parseArgs([ + "models", + "--no-auth", + "--inference-url=https://gateway.example/v1", + ])).toMatchObject({ + command: "models", + noAuth: true, + inferenceUrl: "https://gateway.example", + }); + }); + + it("rejects an invalid inference URL while parsing", () => { + expect(() => parseArgs(["claude", "--inference-url", "ftp://gateway.example"])) + .toThrow("Use an http(s) URL"); + expect(() => parseArgs(["claude", "--inference-url"])) + .toThrow("--inference-url needs a value"); + }); +}); diff --git a/tests/endpoints.test.ts b/tests/endpoints.test.ts new file mode 100644 index 0000000..bd6aa76 --- /dev/null +++ b/tests/endpoints.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { normalizeInferenceRoot, resolveInference } from "../src/endpoints.js"; + +describe("inference endpoints", () => { + it("accepts a root or a URL ending in /v1 and drops trailing slashes", () => { + expect(normalizeInferenceRoot("https://gateway.example/")).toBe("https://gateway.example"); + expect(normalizeInferenceRoot("https://gateway.example/v1")).toBe("https://gateway.example"); + expect(normalizeInferenceRoot("https://gateway.example/v1/")).toBe("https://gateway.example"); + expect(normalizeInferenceRoot("http://localhost:8080/v1")).toBe("http://localhost:8080"); + expect(normalizeInferenceRoot("https://gateway.example/prefix/v1/")).toBe("https://gateway.example/prefix"); + }); + + it("rejects invalid, non-http, or non-root URLs", () => { + expect(() => normalizeInferenceRoot("not a url")).toThrow("Invalid inference URL"); + expect(() => normalizeInferenceRoot("ftp://gateway.example")).toThrow("Use an http(s) URL"); + expect(() => normalizeInferenceRoot("https://gateway.example/v1?key=value")).toThrow("Remove the query string"); + expect(() => normalizeInferenceRoot("https://gateway.example/v1#hash")).toThrow("Remove the query string"); + }); + + it("resolves the inference root by flag, env, endpoint root, then default", () => { + const env = { + ZRO_INFERENCE_URL: "https://env.example/v1/", + ZRO_ENDPOINT_ROOT: "https://endpoint.example", + }; + expect(resolveInference(env, "https://flag.example/v1").root).toBe("https://flag.example"); + expect(resolveInference(env).root).toBe("https://env.example"); + expect(resolveInference({ ZRO_ENDPOINT_ROOT: "https://endpoint.example" }).root) + .toBe("https://endpoint.example"); + expect(resolveInference({}).root).toBe("https://zro.moonmath.ai"); + expect(resolveInference(env, "https://flag.example").baseUrl).toBe("https://flag.example/v1"); + }); + + it("requires an explicit override in no-auth mode", () => { + expect(() => resolveInference({ ZRO_ENDPOINT_ROOT: "https://endpoint.example" }, undefined, true)) + .toThrow("No-auth mode needs an inference URL"); + expect(resolveInference({ ZRO_INFERENCE_URL: "https://env.example" }, undefined, true).root) + .toBe("https://env.example"); + expect(resolveInference({}, "https://flag.example", true).root).toBe("https://flag.example"); + }); +}); diff --git a/tests/hermes.test.ts b/tests/hermes.test.ts index c137770..01ce3ee 100644 --- a/tests/hermes.test.ts +++ b/tests/hermes.test.ts @@ -60,6 +60,10 @@ function context(homeDir: string, model: string): LaunchContext { return { apiKey: "sk-hermes-secret", apiKeySource: "env", + inferenceRoot: "https://zro.moonmath.ai", + inferenceBaseUrl: "https://zro.moonmath.ai/v1", + mcpUrl: "https://zro.moonmath.ai/mcp/zro", + noAuth: false, env: {}, model, models: TIER_MODELS, diff --git a/tests/modalities.test.ts b/tests/modalities.test.ts index e6946f2..042c74f 100644 --- a/tests/modalities.test.ts +++ b/tests/modalities.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { parseModelCatalog } from "../src/model-catalog.js"; -import { buildCodexConfig } from "../src/engine/tools/codex.js"; +import { buildCodexConfig, buildCodexModelCatalog } from "../src/engine/tools/codex.js"; +import { testModel } from "./fixtures.js"; function catalogModel(id: string, codexEffort: string) { return { @@ -69,3 +70,17 @@ describe("codex config clamps remote reasoning efforts", () => { expect(config).toContain('model_reasoning_effort = "xhigh"'); }); }); + +describe("codex model catalog modalities", () => { + it("keeps only input modalities Codex supports", () => { + const catalog = buildCodexModelCatalog([ + testModel("mixed", { input: ["text", "video", "pdf", "image", "audio"], output: ["text"] }), + testModel("non-text", { input: ["video", "pdf"], output: ["text"] }) + ]); + const models = catalog.models as Array<{ input_modalities: string[] }>; + expect(models.map((model) => model.input_modalities)).toEqual([ + ["text", "image", "audio"], + ["text"] + ]); + }); +}); diff --git a/tests/no-auth.test.ts b/tests/no-auth.test.ts new file mode 100644 index 0000000..a6e0431 --- /dev/null +++ b/tests/no-auth.test.ts @@ -0,0 +1,266 @@ +import type { ChildProcess } from "node:child_process"; +import { EventEmitter } from "node:events"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { PassThrough } from "node:stream"; +import { describe, expect, it } from "vitest"; +import { TEST_CATALOG_RESPONSE, TEST_MODELS } from "./fixtures.js"; +import type { LaunchContext, SpawnOptions, SpawnProcess, ToolModule } from "../src/engine/types.js"; +import { claudeTool } from "../src/engine/tools/claude.js"; +import { codexAppTool, codexTool } from "../src/engine/tools/codex.js"; +import { grokTool } from "../src/engine/tools/grok.js"; +import { hermesTool } from "../src/engine/tools/hermes.js"; +import { kiloTool } from "../src/engine/tools/kilo.js"; +import { ompTool } from "../src/engine/tools/omp.js"; +import { openClawTool } from "../src/engine/tools/openclaw.js"; +import { opencodeTool } from "../src/engine/tools/opencode.js"; +import { piTool } from "../src/engine/tools/pi.js"; +import { primeTool } from "../src/engine/tools/prime.js"; +import { run } from "../src/run.js"; + +const GATEWAY_ROOT = "https://gateway.example"; +const GATEWAY_BASE_URL = `${GATEWAY_ROOT}/v1`; +const PUBLIC_CATALOG_URL = "https://zro.moonmath.ai/api/cli/models/public"; + +const tools: readonly ToolModule[] = [ + claudeTool, + codexTool, + codexAppTool, + grokTool, + hermesTool, + kiloTool, + ompTool, + openClawTool, + opencodeTool, + piTool, + primeTool, +]; + +describe("no-auth gateway mode", () => { + it("launches against the gateway using only gateway-available public models", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "zro-no-auth-launch-")); + const calls: Array<{ url: string; authorization: string | null }> = []; + + const code = await run(["claude"], { + ...io(home), + env: { + ZRO_NO_AUTH: "1", + ZRO_INFERENCE_URL: `${GATEWAY_BASE_URL}/`, + }, + fetch: async (input, init) => { + const url = String(input); + calls.push({ url, authorization: new Headers(init?.headers).get("Authorization") }); + if (url === PUBLIC_CATALOG_URL) return Response.json(TEST_CATALOG_RESPONSE); + if (url === `${GATEWAY_BASE_URL}/models`) { + return Response.json({ data: [{ id: "zro/kimi-k3" }] }); + } + throw new Error(`Unexpected request: ${url}`); + }, + spawn: fakeExitSpawn(() => {}), + }); + + expect(code).toBe(0); + expect(calls).toEqual([ + { url: PUBLIC_CATALOG_URL, authorization: null }, + { url: `${GATEWAY_BASE_URL}/models`, authorization: null }, + ]); + + const cacheRoot = path.join(home, ".cache", "zro"); + const publicCache = JSON.parse( + await fs.readFile(path.join(cacheRoot, "model-catalog-public.json"), "utf8"), + ) as { default: string; models: Array<{ id: string }> }; + expect(publicCache.models.map((model) => model.id)).toEqual(["glm-5.3", "kimi-k3"]); + await expect(fs.stat(path.join(cacheRoot, "model-catalog.json"))) + .rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("filters the catalog down to models the gateway actually serves", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "zro-no-auth-filter-")); + const stdout = new PassThrough(); + + const code = await run(["models", "--json"], { + ...io(home), + stdout, + env: { + ZRO_NO_AUTH: "1", + ZRO_INFERENCE_URL: GATEWAY_BASE_URL, + }, + fetch: async (input) => { + const url = String(input); + if (url === PUBLIC_CATALOG_URL) return Response.json(TEST_CATALOG_RESPONSE); + if (url === `${GATEWAY_BASE_URL}/models`) { + return Response.json({ data: [{ id: "zro/kimi-k3" }, { id: "retired-model" }] }); + } + throw new Error(`Unexpected request: ${url}`); + }, + }); + + expect(code).toBe(0); + const catalog = JSON.parse(await streamText(stdout)); + expect(catalog.source).toBe("remote"); + expect(catalog.default).toBe("kimi-k3"); + expect(catalog.models.map((model: { id: string }) => model.id)).toEqual(["kimi-k3"]); + }); + + it("shows the placeholder key in previews without exposing a secret", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "zro-no-auth-preview-")); + const stdout = new PassThrough(); + + const code = await run(["claude", "--json"], { + ...io(home), + stdout, + env: { + ZRO_NO_AUTH: "1", + ZRO_INFERENCE_URL: GATEWAY_BASE_URL, + }, + fetch: async (input) => { + const url = String(input); + if (url === PUBLIC_CATALOG_URL) return Response.json(TEST_CATALOG_RESPONSE); + if (url === `${GATEWAY_BASE_URL}/models`) { + return Response.json({ data: [{ id: "zro/glm-5.3" }] }); + } + throw new Error(`Unexpected request: ${url}`); + }, + }); + + expect(code).toBe(0); + const preview = JSON.parse(await streamText(stdout)); + expect(preview.environment.ANTHROPIC_BASE_URL).toBe(GATEWAY_ROOT); + expect(preview.environment.ANTHROPIC_AUTH_TOKEN).toBe("zro-no-auth"); + await expect(fs.stat(path.join(home, ".cache", "zro", "model-catalog-public.json"))) + .rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("shows the gateway mode in status instead of not logged in", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "zro-no-auth-status-")); + const stdout = new PassThrough(); + + const code = await run(["status"], { + ...io(home), + stdout, + env: { + ZRO_NO_AUTH: "1", + ZRO_INFERENCE_URL: GATEWAY_BASE_URL, + }, + }); + + expect(code).toBe(0); + const output = await streamText(stdout); + expect(output).toContain("No-auth gateway mode"); + expect(output).toContain(GATEWAY_ROOT); + expect(output).not.toContain("Not logged in"); + }); + + it("rejects feedback in no-auth mode", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "zro-no-auth-feedback-")); + const stderr = new PassThrough(); + + const code = await run(["feedback", "hello"], { + ...io(home), + stderr, + env: { + ZRO_NO_AUTH: "1", + ZRO_INFERENCE_URL: GATEWAY_BASE_URL, + }, + }); + + expect(code).toBe(1); + expect(await streamText(stderr)).toContain("unavailable in no-auth mode"); + }); + + it("requires an explicit inference override for no-auth launches and models", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "zro-no-auth-override-")); + const launchStderr = new PassThrough(); + const modelsStderr = new PassThrough(); + + const launchCode = await run(["claude"], { + ...io(home), + stderr: launchStderr, + env: { ZRO_NO_AUTH: "1", ZRO_ENDPOINT_ROOT: "https://endpoint.example" }, + }); + const modelsCode = await run(["models"], { + ...io(home), + stderr: modelsStderr, + env: { ZRO_NO_AUTH: "1", ZRO_ENDPOINT_ROOT: "https://endpoint.example" }, + }); + + expect(launchCode).toBe(1); + expect(modelsCode).toBe(1); + expect(await streamText(launchStderr)).toContain("No-auth mode needs an inference URL"); + expect(await streamText(modelsStderr)).toContain("No-auth mode needs an inference URL"); + }); + + it.each(tools.map((tool) => [tool.id, tool] as const))( + "points %s at the gateway and omits the Zro MCP server", + async (_id, tool) => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), `zro-no-auth-${tool.id}-`)); + const plan = await tool.launch(context(home)); + + if (tool.id === "claude") { + expect(plan.env?.ANTHROPIC_BASE_URL).toBe(GATEWAY_ROOT); + expect(plan.env?.ANTHROPIC_AUTH_TOKEN).toBe("zro-no-auth"); + expect(plan.args).not.toContain("--mcp-config"); + } else { + expect(plan.env?.ZRO_API_KEY).toBe("zro-no-auth"); + } + + const files = (plan.files ?? []).map((file) => String(file.contents)).join("\n"); + const envText = JSON.stringify(plan.env); + if (tool.id !== "claude") expect(files + envText).toContain(GATEWAY_BASE_URL); + expect(files + envText).not.toContain("/mcp/zro"); + expect(files + envText).not.toContain("Authorization"); + }, + ); +}); + +function context(homeDir: string): LaunchContext { + return { + apiKey: "zro-no-auth", + apiKeySource: "none", + inferenceRoot: GATEWAY_ROOT, + inferenceBaseUrl: GATEWAY_BASE_URL, + mcpUrl: "https://zro.moonmath.ai/mcp/zro", + noAuth: true, + env: {}, + model: "glm-5.3", + models: TEST_MODELS, + extraArgs: [], + homeDir, + cwd: homeDir, + tempDir: path.join(homeDir, "session"), + platform: "linux", + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + }; +} + +function io(home: string) { + return { + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + homeDir: home, + cwd: home, + env: {}, + platform: "linux" as const, + version: "0.0.1", + }; +} + +function fakeExitSpawn(assertion: (command: string, args: string[], options: SpawnOptions) => void): SpawnProcess { + return ((command: string, args: string[], options: SpawnOptions) => { + assertion(command, args, options); + const child = new EventEmitter() as ChildProcess; + queueMicrotask(() => child.emit("exit", 0, null)); + return child; + }) as SpawnProcess; +} + +async function streamText(stream: PassThrough): Promise { + stream.end(); + let output = ""; + for await (const chunk of stream) output += chunk.toString(); + return output; +} diff --git a/tests/omp.test.ts b/tests/omp.test.ts index 32d9fb0..8027c6e 100644 --- a/tests/omp.test.ts +++ b/tests/omp.test.ts @@ -311,6 +311,10 @@ function context( return { apiKey: "sk-omp-secret", apiKeySource: "env", + inferenceRoot: "https://zro.moonmath.ai", + inferenceBaseUrl: BASE_URL, + mcpUrl: MCP_URL, + noAuth: false, env, model: "glm-5.3", models: TEST_MODELS, diff --git a/tests/prime.test.ts b/tests/prime.test.ts index 4508b13..b1c16c4 100644 --- a/tests/prime.test.ts +++ b/tests/prime.test.ts @@ -32,6 +32,10 @@ function context(homeDir: string): LaunchContext { return { apiKey: "sk-prime-secret", apiKeySource: "env", + inferenceRoot: "https://zro.moonmath.ai", + inferenceBaseUrl: "https://zro.moonmath.ai/v1", + mcpUrl: "https://zro.moonmath.ai/mcp/zro", + noAuth: false, env: {}, model: "glm-5.3", models: TEST_MODELS, diff --git a/tests/run.test.ts b/tests/run.test.ts index 1ac791f..66711b2 100644 --- a/tests/run.test.ts +++ b/tests/run.test.ts @@ -268,6 +268,10 @@ describe("zro experience", () => { const plan = await claudeTool.launch({ apiKey: "sk-boundary-secret", apiKeySource: "env", + inferenceRoot: "https://zro.moonmath.ai", + inferenceBaseUrl: "https://zro.moonmath.ai/v1", + mcpUrl: "https://zro.moonmath.ai/mcp/zro", + noAuth: false, env: {}, model: "kimi-k3", models, @@ -304,6 +308,10 @@ describe("zro experience", () => { const plan = await claudeTool.launch({ apiKey: "sk-boundary-secret", apiKeySource: "env", + inferenceRoot: "https://zro.moonmath.ai", + inferenceBaseUrl: "https://zro.moonmath.ai/v1", + mcpUrl: "https://zro.moonmath.ai/mcp/zro", + noAuth: false, env: {}, model: "anthropic.injected", models, @@ -359,6 +367,10 @@ describe("zro experience", () => { const plan = await claudeTool.launch({ apiKey: "sk-warn-secret", apiKeySource: "env", + inferenceRoot: "https://zro.moonmath.ai", + inferenceBaseUrl: "https://zro.moonmath.ai/v1", + mcpUrl: "https://zro.moonmath.ai/mcp/zro", + noAuth: false, env: {}, model: "uncatalogued-model", models: [],