Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added

- `--inference-url <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
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
```

---
Expand Down
2 changes: 1 addition & 1 deletion scripts/ci/check-live.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
}
Expand Down
39 changes: 35 additions & 4 deletions src/args.ts
Original file line number Diff line number Diff line change
@@ -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"]);
Expand Down Expand Up @@ -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") {
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -211,6 +235,13 @@ function requiredValue(argv: string[], index: number, option: string): string {
return value;
}

function endpointOptions(options: ParsedOptions): Pick<ParsedOptions, "inferenceUrl" | "noAuth"> {
return {
...(options.inferenceUrl ? { inferenceUrl: options.inferenceUrl } : {}),
...(options.noAuth ? { noAuth: true } : {}),
};
}

function outputMode(argv: string[]): OutputMode {
return argv.includes("--json") ? "json" : "human";
}
Expand Down
57 changes: 57 additions & 0 deletions src/endpoints.ts
Original file line number Diff line number Diff line change
@@ -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";
}
8 changes: 5 additions & 3 deletions src/engine/constants.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
20 changes: 10 additions & 10 deletions src/engine/tools/claude.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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",
Expand All @@ -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}` }
}
}
Expand Down Expand Up @@ -197,14 +196,15 @@ function buildClaudeModelEnv(
modelSpecs: readonly ZroModel[],
aliasPlan: ClaudeAliasPlan,
sessionBudget: number | undefined,
sessionOutputBudget: number | undefined
sessionOutputBudget: number | undefined,
inferenceRoot: string
): Record<string, string> {
const aliasMapping = aliasPlan.mapping;

const env: Record<string, string> = {
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
Expand All @@ -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) {
Expand Down
Loading
Loading