diff --git a/src/agents/claude-code.ts b/src/agents/claude-code.ts index 10b1f23..9875549 100644 --- a/src/agents/claude-code.ts +++ b/src/agents/claude-code.ts @@ -8,8 +8,6 @@ import type { OpperRouting, } from "./types.js"; -import { OPPER_COMPAT_URL } from "../config/endpoints.js"; - // Claude Code reads ANTHROPIC_BASE_URL and appends `/v1/messages` for // inference and `/v1/models` for the /model picker. Opper's compat // endpoint at `/v3/compat` serves both, so the picker auto-populates @@ -51,7 +49,7 @@ async function spawn(args: string[], routing: OpperRouting): Promise { // the rest from `${ANTHROPIC_BASE_URL}/v1/models`. const env: NodeJS.ProcessEnv = { ...process.env, - ANTHROPIC_BASE_URL: OPPER_COMPAT_URL, + ANTHROPIC_BASE_URL: routing.baseUrl, ANTHROPIC_AUTH_TOKEN: routing.apiKey, ANTHROPIC_MODEL: routing.model, // Suppress telemetry/auto-update/error-report calls that Claude Code diff --git a/src/agents/codex.ts b/src/agents/codex.ts index 2cef487..d226c31 100644 --- a/src/agents/codex.ts +++ b/src/agents/codex.ts @@ -7,6 +7,7 @@ import { which } from "../util/which.js"; import { npmInstallGlobal } from "./npm-install.js"; import type { AgentAdapter, + ConfigureOptions, DetectResult, OpperRouting, } from "./types.js"; @@ -18,27 +19,29 @@ const SENTINEL_OPEN = "# >>> opper-cli >>>"; const SENTINEL_CLOSE = "# <<< opper-cli <<<"; const DEFAULT_PROFILE = "opper-opus"; -const OPPER_BLOCK = [ - SENTINEL_OPEN, - "# Managed by `opper`. Edits between these markers will be overwritten", - "# the next time you reconfigure Codex via the Opper CLI.", - "", - "[model_providers.opper]", - 'name = "Opper"', - `base_url = "${OPPER_COMPAT_URL}"`, - 'env_key = "OPPER_API_KEY"', - 'wire_api = "responses"', - "", - "[profiles.opper-opus]", - `model = "${DEFAULT_MODELS.opus}"`, - 'model_provider = "opper"', - "", - "[profiles.opper-sonnet]", - `model = "${DEFAULT_MODELS.sonnet}"`, - 'model_provider = "opper"', - SENTINEL_CLOSE, - "", -].join("\n"); +function buildOpperBlock(baseUrl: string): string { + return [ + SENTINEL_OPEN, + "# Managed by `opper`. Edits between these markers will be overwritten", + "# the next time you reconfigure Codex via the Opper CLI.", + "", + "[model_providers.opper]", + 'name = "Opper"', + `base_url = "${baseUrl}"`, + 'env_key = "OPPER_API_KEY"', + 'wire_api = "responses"', + "", + "[profiles.opper-opus]", + `model = "${DEFAULT_MODELS.opus}"`, + 'model_provider = "opper"', + "", + "[profiles.opper-sonnet]", + `model = "${DEFAULT_MODELS.sonnet}"`, + 'model_provider = "opper"', + SENTINEL_CLOSE, + "", + ].join("\n"); +} function codexConfigPath(): string { return join(homedir(), ".codex", "config.toml"); @@ -73,16 +76,17 @@ async function isConfigured(): Promise { if (!existsSync(cfg)) return false; try { const text = readFileSync(cfg, "utf8"); + // Match the sentinel + the start of the base_url line, but don't pin + // the URL value — at launch we rewrite it to the per-session URL. return ( - text.includes(SENTINEL_OPEN) && - text.includes(`base_url = "${OPPER_COMPAT_URL}"`) + text.includes(SENTINEL_OPEN) && /base_url = "/.test(text) ); } catch { return false; } } -async function configure(): Promise { +async function writeOpperBlock(baseUrl: string): Promise { const cfg = codexConfigPath(); let existing = ""; if (existsSync(cfg)) { @@ -93,17 +97,26 @@ async function configure(): Promise { } } const cleaned = stripOpperBlock(existing); + const block = buildOpperBlock(baseUrl); const padded = cleaned.length === 0 - ? OPPER_BLOCK + ? block : cleaned.endsWith("\n") - ? cleaned + OPPER_BLOCK - : `${cleaned}\n${OPPER_BLOCK}`; + ? cleaned + block + : `${cleaned}\n${block}`; await mkdir(dirname(cfg), { recursive: true }); await writeFile(cfg, padded, "utf8"); } +async function configure(_opts: ConfigureOptions): Promise { + // Configure-only flow (menu / `opper agents`): bake in the default + // compat URL. At launch time `spawn` rewrites the block with the + // session-specific URL. + void _opts; + await writeOpperBlock(OPPER_COMPAT_URL); +} + async function unconfigure(): Promise { const cfg = codexConfigPath(); if (!existsSync(cfg)) return; @@ -128,8 +141,9 @@ function hasProfileArg(args: string[]): boolean { } async function spawn(args: string[], routing: OpperRouting): Promise { - // Ensure our provider/profile block is present (first-launch ergonomics). - await configure(); + // Rewrite our provider/profile block on every launch so the latest + // session URL (and any tags it carries) is the active base_url. + await writeOpperBlock(routing.baseUrl); const env: NodeJS.ProcessEnv = { ...process.env, diff --git a/src/agents/openclaw.ts b/src/agents/openclaw.ts index 201dc61..4031578 100644 --- a/src/agents/openclaw.ts +++ b/src/agents/openclaw.ts @@ -46,13 +46,17 @@ async function writeConfig(data: ModelsFile): Promise { await writeFile(cfg, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 }); } -async function setOpperProvider(apiKey: string, launchModel: string): Promise { +async function setOpperProvider( + apiKey: string, + launchModel: string, + baseUrl: string, +): Promise { const cfg = await readConfig(); cfg.providers = cfg.providers ?? {}; cfg.providers[PROVIDER_KEY] = { api: "openai-completions", apiKey, - baseUrl: OPPER_COMPAT_URL, + baseUrl, models: [ { id: launchModel, @@ -117,7 +121,7 @@ async function configure(opts: ConfigureOptions): Promise { "Run `opper login` first, or set OPPER_API_KEY.", ); } - await setOpperProvider(opts.apiKey, DEFAULT_MODELS.opus); + await setOpperProvider(opts.apiKey, DEFAULT_MODELS.opus, OPPER_COMPAT_URL); } async function unconfigure(): Promise { @@ -129,9 +133,9 @@ async function unconfigure(): Promise { } async function spawn(args: string[], routing: OpperRouting): Promise { - // Ensure our provider is current with the latest credentials and the - // chosen launch model on every spawn. - await setOpperProvider(routing.apiKey, routing.model); + // Ensure our provider is current with the latest credentials, the + // chosen launch model, and the per-session base URL on every spawn. + await setOpperProvider(routing.apiKey, routing.model, routing.baseUrl); // OpenClaw is a gateway/daemon, not an interactive REPL. Default to // `gateway start` — installs/starts the background service via diff --git a/src/agents/opencode.ts b/src/agents/opencode.ts index 4f527d6..3f700f1 100644 --- a/src/agents/opencode.ts +++ b/src/agents/opencode.ts @@ -68,12 +68,42 @@ async function unconfigure(): Promise { await writeFile(cfg, JSON.stringify(parsed, null, 2), "utf8"); } +/** + * Rewrite `provider.opper.options.baseURL` in the existing opencode.json to + * the per-launch URL (typically a /v3/session// URL). The + * template only writes once via `configureOpenCode`, so without this step + * launching a session would fall back to the default compat URL baked into + * the template. + */ +async function setSessionBaseUrl( + baseUrl: string, + location: "global" | "local", +): Promise { + const cfg = opencodeConfigPath(location); + if (!existsSync(cfg)) return; + let parsed: { + provider?: Record }>; + [k: string]: unknown; + }; + try { + parsed = JSON.parse(readFileSync(cfg, "utf8")); + } catch { + return; + } + const opper = parsed.provider?.opper; + if (!opper) return; + opper.options = opper.options ?? {}; + opper.options.baseURL = baseUrl; + await writeFile(cfg, JSON.stringify(parsed, null, 2), "utf8"); +} + async function spawn( args: string[], routing: OpperRouting, opts: SpawnOptions = {}, ): Promise { const scope = opts.configScope ?? "user"; + const location = scope === "project" ? "local" : "global"; if (scope === "project") { // Explicit opt-in to writing the cwd-local config. We never silently @@ -98,6 +128,10 @@ async function spawn( } } + // Rewrite the baseURL to the per-session URL on every launch so + // generations land on the right session. + await setSessionBaseUrl(routing.baseUrl, location); + const env: NodeJS.ProcessEnv = { ...process.env, OPPER_API_KEY: routing.apiKey, diff --git a/src/agents/pi.ts b/src/agents/pi.ts index e700035..68bcf40 100644 --- a/src/agents/pi.ts +++ b/src/agents/pi.ts @@ -49,13 +49,17 @@ async function writeConfig(data: PiModelsFile): Promise { * Idempotently install our `opper` provider entry. Other providers in the * same file (ollama, etc.) are preserved. */ -async function setOpperProvider(apiKey: string, launchModel: string): Promise { +async function setOpperProvider( + apiKey: string, + launchModel: string, + baseUrl: string, +): Promise { const cfg = await readConfig(); cfg.providers = cfg.providers ?? {}; cfg.providers[PROVIDER_KEY] = { api: "openai-completions", apiKey, - baseUrl: OPPER_COMPAT_URL, + baseUrl, models: [ { id: launchModel, @@ -115,7 +119,7 @@ async function configure(opts: ConfigureOptions): Promise { "Run `opper login` first, or set OPPER_API_KEY.", ); } - await setOpperProvider(opts.apiKey, DEFAULT_MODELS.opus); + await setOpperProvider(opts.apiKey, DEFAULT_MODELS.opus, OPPER_COMPAT_URL); } async function unconfigure(): Promise { @@ -127,9 +131,9 @@ async function unconfigure(): Promise { } async function spawn(args: string[], routing: OpperRouting): Promise { - // Re-write the provider on every launch so the latest credentials and the - // chosen launch model are always the active ones. - await setOpperProvider(routing.apiKey, routing.model); + // Re-write the provider on every launch so the latest credentials, the + // chosen launch model, and the per-session base URL are always active. + await setOpperProvider(routing.apiKey, routing.model, routing.baseUrl); // pi's CLI requires *both* --provider and --model to resolve a non-default // provider — passing only --provider falls through to the auto-resolver diff --git a/src/cli/agents.ts b/src/cli/agents.ts index 397ad6e..21bde46 100644 --- a/src/cli/agents.ts +++ b/src/cli/agents.ts @@ -2,6 +2,33 @@ import { agentsListCommand, agentsUninstallCommand } from "../commands/agents.js import { launchCommand } from "../commands/launch.js"; import type { RegisterFn } from "./types.js"; +export function collectTagPairs( + raw: string, + acc: Record, +): Record { + const eq = raw.indexOf("="); + if (eq <= 0) { + throw new Error(`--tag expects key=value, got "${raw}"`); + } + const key = raw.slice(0, eq); + const value = raw.slice(eq + 1); + // Only reject the multi-pair shorthand (a=1,b=2): a comma followed by + // another `key=` pair. Plain commas inside a value (`Acme, Inc`) are fine. + const commaIdx = value.indexOf(","); + if (commaIdx >= 0) { + const after = value.slice(commaIdx + 1).trimStart(); + if (/^[a-zA-Z][a-zA-Z0-9_.-]*=/.test(after)) { + throw new Error( + `--tag does not accept multiple pairs in one flag — pass each as a separate --tag (got "${raw}")`, + ); + } + } + if (Object.prototype.hasOwnProperty.call(acc, key)) { + throw new Error(`--tag key "${key}" specified twice`); + } + return { ...acc, [key]: value }; +} + const register: RegisterFn = (program, ctx) => { const agentsCmd = program .command("agents") @@ -38,12 +65,23 @@ const register: RegisterFn = (program, ctx) => { "write the Opper config into the cwd-local project config (where supported, e.g. opencode) instead of the user-level config", false, ) + .option( + "--tag ", + "attach metadata as key=value (repeatable). Stored as a tag on every generation in this launch.", + collectTagPairs, + {} as Record, + ) .allowUnknownOption(true) .allowExcessArguments(true) .action( async ( agentName: string, - cmdOpts: { model?: string; install?: boolean; project?: boolean }, + cmdOpts: { + model?: string; + install?: boolean; + project?: boolean; + tag?: Record; + }, cmd, ) => { const args = (cmd.args as string[]).slice(1); @@ -53,6 +91,9 @@ const register: RegisterFn = (program, ctx) => { ...(cmdOpts.model ? { model: cmdOpts.model } : {}), ...(cmdOpts.install ? { install: true } : {}), ...(cmdOpts.project ? { configScope: "project" as const } : {}), + ...(cmdOpts.tag && Object.keys(cmdOpts.tag).length > 0 + ? { tags: cmdOpts.tag } + : {}), passthrough: args, }); process.exit(code); diff --git a/src/commands/launch.ts b/src/commands/launch.ts index 07dbac1..1af10a7 100644 --- a/src/commands/launch.ts +++ b/src/commands/launch.ts @@ -4,12 +4,12 @@ import { getSlot } from "../auth/config.js"; import { loginCommand } from "./login.js"; import { OpperError } from "../errors.js"; import { brand } from "../ui/colors.js"; -import { OPPER_COMPAT_URL } from "../config/endpoints.js"; import { DEFAULT_MODELS } from "../config/models.js"; import { OpperApi } from "../api/client.js"; import { resolveApiContext } from "../api/resolve.js"; import type { OpperRouting, SpawnOptions } from "../agents/types.js"; import { formatSessionSummary, type ModelUsage } from "./launch-summary.js"; +import { newSessionId, buildSessionBaseUrl } from "../util/session-url.js"; const TRACES_URL = "https://platform.opper.ai/traces"; @@ -20,6 +20,7 @@ export interface LaunchOptions { install?: boolean; passthrough?: string[]; configScope?: SpawnOptions["configScope"]; + tags?: Record; } export async function launchCommand(opts: LaunchOptions): Promise { @@ -72,8 +73,12 @@ export async function launchCommand(opts: LaunchOptions): Promise { await adapter.install(); } + const host = process.env.OPPER_BASE_URL ?? slot.baseUrl ?? "https://api.opper.ai"; + const sessionId = newSessionId(); + const baseUrl = buildSessionBaseUrl(host, sessionId, opts.tags ?? {}); + const routing: OpperRouting = { - baseUrl: OPPER_COMPAT_URL, + baseUrl, apiKey: slot.apiKey, model: opts.model ?? DEFAULT_MODELS.opus, compatShape: "openai", @@ -109,6 +114,7 @@ export async function launchCommand(opts: LaunchOptions): Promise { try { await printSessionSummary({ key: opts.key, + sessionId, startedAt, endedAt, }); @@ -121,6 +127,7 @@ export async function launchCommand(opts: LaunchOptions): Promise { interface SummaryOptions { key: string; + sessionId: string; startedAt: Date; endedAt: Date; } @@ -154,16 +161,12 @@ async function printSessionSummary(opts: SummaryOptions): Promise { let rows: UsageRow[] = []; try { rows = await api.get("/v2/analytics/usage", { - from_date: opts.startedAt.toISOString(), - to_date: opts.endedAt.toISOString(), - // Without granularity the endpoint returns daily buckets, which - // would sweep up the entire day's spend instead of just this - // session. + session_id: opts.sessionId, + // session_id alone scopes to this launch, but we still pass granularity + // so the response shape stays consistent and tokens aggregate per minute + // bucket. group_by=model lets us render a per-model breakdown. granularity: "minute", fields: "total_tokens", - // Group by model so the summary reflects every model the agent - // actually called (e.g. /model picker switches, Haiku for compaction) - // rather than just the launch-time default. group_by: "model", }); } catch { diff --git a/src/util/session-url.ts b/src/util/session-url.ts new file mode 100644 index 0000000..356d6f0 --- /dev/null +++ b/src/util/session-url.ts @@ -0,0 +1,42 @@ +import { randomUUID } from "node:crypto"; + +const KEY_REGEX = /^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$/; +const MAX_TAGS = 8; +const MAX_VALUE_BYTES = 256; + +export function newSessionId(): string { + return `sess_${randomUUID()}`; +} + +export function validateTags(tags: Record): void { + const keys = Object.keys(tags); + if (keys.length > MAX_TAGS) { + throw new Error(`too many tags: max ${MAX_TAGS}`); + } + for (const k of keys) { + if (!KEY_REGEX.test(k)) { + throw new Error(`invalid tag key: ${k}`); + } + if (k.toLowerCase().startsWith("opper.")) { + throw new Error(`reserved tag key: ${k}`); + } + const v = tags[k] ?? ""; + if (Buffer.byteLength(v, "utf8") > MAX_VALUE_BYTES) { + throw new Error(`value too long for ${k}: max ${MAX_VALUE_BYTES} bytes`); + } + } +} + +export function buildSessionBaseUrl( + host: string, + sessionId: string, + tags: Record, +): string { + validateTags(tags); + const cleanHost = host.replace(/\/+$/, ""); + const sortedKeys = Object.keys(tags).sort(); + const pairs = sortedKeys.map( + (k) => `/${k}:${encodeURIComponent(tags[k] ?? "")}`, + ); + return `${cleanHost}/v3/session/${sessionId}${pairs.join("")}`; +} diff --git a/test/agents/claude-code.test.ts b/test/agents/claude-code.test.ts index ecceab3..00444ba 100644 --- a/test/agents/claude-code.test.ts +++ b/test/agents/claude-code.test.ts @@ -17,7 +17,7 @@ vi.mock("node:child_process", async () => { const { claudeCode } = await import("../../src/agents/claude-code.js"); const ROUTING = { - baseUrl: "ignored-by-this-adapter", + baseUrl: "https://api.opper.ai/v3/session/sess_aa11bb22-cccc-4ddd-8eee-ffff00001111/customer:acme", apiKey: "op_live_run", model: "claude-sonnet-4-6", compatShape: "openai" as const, @@ -90,7 +90,9 @@ describe("claude-code adapter", () => { expect(call[0]).toBe("claude"); expect(call[1]).toEqual(["chat"]); const init = call[2] as { env: NodeJS.ProcessEnv }; - expect(init.env.ANTHROPIC_BASE_URL).toBe("https://api.opper.ai/v3/compat"); + expect(init.env.ANTHROPIC_BASE_URL).toBe( + "https://api.opper.ai/v3/session/sess_aa11bb22-cccc-4ddd-8eee-ffff00001111/customer:acme", + ); expect(init.env.ANTHROPIC_AUTH_TOKEN).toBe("op_live_run"); expect(init.env.ANTHROPIC_MODEL).toBe("claude-sonnet-4-6"); // Stops Claude Code from pinging api.anthropic.com directly when diff --git a/test/agents/codex.test.ts b/test/agents/codex.test.ts index f684a7e..6766305 100644 --- a/test/agents/codex.test.ts +++ b/test/agents/codex.test.ts @@ -26,6 +26,9 @@ vi.mock("node:child_process", async () => { const { codex } = await import("../../src/agents/codex.js"); +const SESSION_URL = + "https://api.opper.ai/v3/session/sess_aa11bb22-cccc-4ddd-8eee-ffff00001111/customer:acme"; + describe("codex adapter", () => { let sandbox: string; let prevHome: string | undefined; @@ -151,7 +154,7 @@ describe("codex adapter", () => { it("spawn injects OPPER_API_KEY and prepends --profile opper-opus when no profile is set", async () => { spawnSyncMock.mockReturnValue({ status: 0 }); const code = await codex.spawn!(["chat"], { - baseUrl: "ignored", + baseUrl: SESSION_URL, apiKey: "op_live_run", model: "claude-opus-4-7", compatShape: "openai", @@ -163,12 +166,19 @@ describe("codex adapter", () => { expect(call[1]).toEqual(["--profile", "opper-opus", "chat"]); const init = call[2] as { env: NodeJS.ProcessEnv }; expect(init.env.OPPER_API_KEY).toBe("op_live_run"); + + // The session URL from routing.baseUrl is written into the config.toml + // managed block — that's how Codex picks up the per-launch session. + const cfgPath = join(sandbox, ".codex", "config.toml"); + const text = readFileSync(cfgPath, "utf8"); + expect(text).toContain(`base_url = "${SESSION_URL}"`); + expect(text).not.toContain('base_url = "https://api.opper.ai/v3/compat"'); }); it("spawn does not add --profile when the user already passed one", async () => { spawnSyncMock.mockReturnValue({ status: 0 }); await codex.spawn!(["--profile", "opper-sonnet", "chat"], { - baseUrl: "ignored", + baseUrl: SESSION_URL, apiKey: "k", model: "m", compatShape: "openai", @@ -180,7 +190,7 @@ describe("codex adapter", () => { it("spawn propagates non-zero exit codes", async () => { spawnSyncMock.mockReturnValue({ status: 2 }); const code = await codex.spawn!([], { - baseUrl: "ignored", + baseUrl: SESSION_URL, apiKey: "k", model: "m", compatShape: "openai", diff --git a/test/agents/openclaw.test.ts b/test/agents/openclaw.test.ts new file mode 100644 index 0000000..fe453f0 --- /dev/null +++ b/test/agents/openclaw.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const whichMock = vi.fn(); +const runMock = vi.fn(); +vi.mock("../../src/util/which.js", () => ({ which: whichMock })); +vi.mock("../../src/util/run.js", () => ({ run: runMock })); + +const spawnSyncMock = vi.fn(); +vi.mock("node:child_process", async () => { + const actual = await vi.importActual( + "node:child_process", + ); + return { ...actual, spawnSync: spawnSyncMock }; +}); + +const { openclaw } = await import("../../src/agents/openclaw.js"); + +const SESSION_URL = + "https://api.opper.ai/v3/session/sess_aa11bb22-cccc-4ddd-8eee-ffff00001111/customer:acme"; + +const ROUTING = { + baseUrl: SESSION_URL, + apiKey: "op_live_run", + model: "claude-opus-4-7", + compatShape: "openai" as const, +}; + +function readModels(sandbox: string): { + providers?: Record; +} { + const cfgPath = join(sandbox, ".openclaw", "agents", "main", "agent", "models.json"); + return JSON.parse(readFileSync(cfgPath, "utf8")); +} + +describe("openclaw adapter", () => { + let sandbox: string; + let prevHome: string | undefined; + + beforeEach(() => { + whichMock.mockReset(); + runMock.mockReset(); + spawnSyncMock.mockReset(); + sandbox = mkdtempSync(join(tmpdir(), "opper-openclaw-")); + prevHome = process.env.HOME; + process.env.HOME = sandbox; + }); + + afterEach(() => { + rmSync(sandbox, { recursive: true, force: true }); + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + }); + + it("metadata is correct", () => { + expect(openclaw.name).toBe("openclaw"); + expect(openclaw.displayName).toBe("OpenClaw"); + expect(typeof openclaw.spawn).toBe("function"); + expect(typeof openclaw.install).toBe("function"); + }); + + it("configure (no apiKey) throws AUTH_REQUIRED", async () => { + await expect(openclaw.configure({})).rejects.toMatchObject({ + code: "AUTH_REQUIRED", + }); + }); + + it("configure with apiKey writes the default compat URL into models.json", async () => { + await openclaw.configure({ apiKey: "op_live_test" }); + const models = readModels(sandbox); + expect(models.providers?.opper).toBeDefined(); + expect(models.providers?.opper?.baseUrl).toBe( + "https://api.opper.ai/v3/compat", + ); + expect(models.providers?.opper?.apiKey).toBe("op_live_test"); + }); + + it("spawn writes routing.baseUrl (the session URL) into models.json before launching", async () => { + spawnSyncMock.mockReturnValue({ status: 0 }); + + const code = await openclaw.spawn!(["agent"], ROUTING); + expect(code).toBe(0); + + const models = readModels(sandbox); + expect(models.providers?.opper?.baseUrl).toBe(SESSION_URL); + expect(models.providers?.opper?.apiKey).toBe("op_live_run"); + + // The default compat URL should NOT have leaked into the file. + const raw = readFileSync( + join(sandbox, ".openclaw", "agents", "main", "agent", "models.json"), + "utf8", + ); + expect(raw).not.toContain('"baseUrl": "https://api.opper.ai/v3/compat"'); + }); + + it("spawn defaults to `gateway start` when no args are passed", async () => { + spawnSyncMock.mockReturnValue({ status: 0 }); + await openclaw.spawn!([], ROUTING); + const call = spawnSyncMock.mock.calls[0]!; + expect(call[0]).toBe("openclaw"); + expect(call[1]).toEqual(["gateway", "start"]); + }); + + it("spawn forwards user-supplied args verbatim", async () => { + spawnSyncMock.mockReturnValue({ status: 0 }); + await openclaw.spawn!(["agent", "--local"], ROUTING); + const call = spawnSyncMock.mock.calls[0]!; + expect(call[1]).toEqual(["agent", "--local"]); + }); + + it("spawn propagates non-zero exit codes", async () => { + spawnSyncMock.mockReturnValue({ status: 2 }); + const code = await openclaw.spawn!(["agent"], ROUTING); + expect(code).toBe(2); + }); + + it("unconfigure removes the opper provider but leaves the file/other providers", async () => { + await openclaw.configure({ apiKey: "op_live_test" }); + expect(existsSync( + join(sandbox, ".openclaw", "agents", "main", "agent", "models.json"), + )).toBe(true); + await openclaw.unconfigure(); + const models = readModels(sandbox); + expect(models.providers?.opper).toBeUndefined(); + }); +}); diff --git a/test/agents/opencode.test.ts b/test/agents/opencode.test.ts index 7bc7df9..e2bbb6f 100644 --- a/test/agents/opencode.test.ts +++ b/test/agents/opencode.test.ts @@ -1,4 +1,13 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + mkdtempSync, + rmSync, + writeFileSync, + readFileSync, + mkdirSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; const whichMock = vi.fn(); vi.mock("../../src/util/which.js", () => ({ which: whichMock })); @@ -23,14 +32,48 @@ vi.mock("node:child_process", async () => { const { opencode } = await import("../../src/agents/opencode.js"); +const SESSION_URL = + "https://api.opper.ai/v3/session/sess_aa11bb22-cccc-4ddd-8eee-ffff00001111/customer:acme"; + const ROUTING = { - baseUrl: "https://api.opper.ai/v3/compat", + baseUrl: SESSION_URL, apiKey: "op_live_run", model: "claude-opus-4-7", compatShape: "openai" as const, }; +function opencodeConfigPath(sandbox: string): string { + return join(sandbox, ".config", "opencode", "opencode.json"); +} + +function seedOpencodeConfig(sandbox: string): void { + const cfgPath = opencodeConfigPath(sandbox); + mkdirSync(join(sandbox, ".config", "opencode"), { recursive: true }); + writeFileSync( + cfgPath, + JSON.stringify( + { + provider: { + opper: { + npm: "@ai-sdk/openai-compatible", + options: { + baseURL: "https://api.opper.ai/v3/compat", + apiKey: "{env:OPPER_API_KEY}", + }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); +} + describe("opencode adapter", () => { + let sandbox: string; + let prevHome: string | undefined; + beforeEach(() => { whichMock.mockReset(); runMock.mockReset(); @@ -42,6 +85,15 @@ describe("opencode adapter", () => { exists: false, hasOpperProvider: false, }); + sandbox = mkdtempSync(join(tmpdir(), "opper-opencode-")); + prevHome = process.env.HOME; + process.env.HOME = sandbox; + }); + + afterEach(() => { + rmSync(sandbox, { recursive: true, force: true }); + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; }); it("metadata is correct", () => { @@ -85,10 +137,11 @@ describe("opencode adapter", () => { it("spawn ensures the provider config exists then runs opencode with OPPER_API_KEY in env", async () => { configureOpenCodeMock.mockResolvedValue({ - path: "/tmp/opencode.json", + path: opencodeConfigPath(sandbox), wrote: true, }); spawnSyncMock.mockReturnValue({ status: 0 }); + seedOpencodeConfig(sandbox); const code = await opencode.spawn!(["chat"], ROUTING); expect(code).toBe(0); @@ -101,9 +154,45 @@ describe("opencode adapter", () => { expect(init.env.OPPER_API_KEY).toBe("op_live_run"); }); + it("spawn rewrites provider.opper.options.baseURL to routing.baseUrl on every launch", async () => { + configureOpenCodeMock.mockResolvedValue({ + path: opencodeConfigPath(sandbox), + wrote: false, + }); + spawnSyncMock.mockReturnValue({ status: 0 }); + seedOpencodeConfig(sandbox); + + await opencode.spawn!([], ROUTING); + + const cfg = JSON.parse( + readFileSync(opencodeConfigPath(sandbox), "utf8"), + ) as { + provider: { opper: { options: { baseURL: string; apiKey: string } } }; + }; + expect(cfg.provider.opper.options.baseURL).toBe(SESSION_URL); + // The {env:OPPER_API_KEY} placeholder is preserved — we only rewrote + // baseURL. + expect(cfg.provider.opper.options.apiKey).toBe("{env:OPPER_API_KEY}"); + }); + + it("spawn does not crash if the opencode.json doesn't exist yet (configureOpenCode was a no-op stub)", async () => { + configureOpenCodeMock.mockResolvedValue({ + path: opencodeConfigPath(sandbox), + wrote: false, + }); + spawnSyncMock.mockReturnValue({ status: 0 }); + // No seedOpencodeConfig — config is missing. + const code = await opencode.spawn!([], ROUTING); + expect(code).toBe(0); + }); + it("spawn propagates non-zero exit codes", async () => { - configureOpenCodeMock.mockResolvedValue({ path: "/tmp/x", wrote: false }); + configureOpenCodeMock.mockResolvedValue({ + path: opencodeConfigPath(sandbox), + wrote: false, + }); spawnSyncMock.mockReturnValue({ status: 2 }); + seedOpencodeConfig(sandbox); const code = await opencode.spawn!([], ROUTING); expect(code).toBe(2); }); diff --git a/test/agents/pi.test.ts b/test/agents/pi.test.ts new file mode 100644 index 0000000..ca08bd7 --- /dev/null +++ b/test/agents/pi.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const whichMock = vi.fn(); +const runMock = vi.fn(); +vi.mock("../../src/util/which.js", () => ({ which: whichMock })); +vi.mock("../../src/util/run.js", () => ({ run: runMock })); + +const spawnSyncMock = vi.fn(); +vi.mock("node:child_process", async () => { + const actual = await vi.importActual( + "node:child_process", + ); + return { ...actual, spawnSync: spawnSyncMock }; +}); + +const { pi } = await import("../../src/agents/pi.js"); + +const SESSION_URL = + "https://api.opper.ai/v3/session/sess_aa11bb22-cccc-4ddd-8eee-ffff00001111/customer:acme"; + +const ROUTING = { + baseUrl: SESSION_URL, + apiKey: "op_live_run", + model: "claude-opus-4-7", + compatShape: "openai" as const, +}; + +function readModels(sandbox: string): { + providers?: Record; +} { + const cfgPath = join(sandbox, ".pi", "agent", "models.json"); + return JSON.parse(readFileSync(cfgPath, "utf8")); +} + +describe("pi adapter", () => { + let sandbox: string; + let prevHome: string | undefined; + + beforeEach(() => { + whichMock.mockReset(); + runMock.mockReset(); + spawnSyncMock.mockReset(); + sandbox = mkdtempSync(join(tmpdir(), "opper-pi-")); + prevHome = process.env.HOME; + process.env.HOME = sandbox; + }); + + afterEach(() => { + rmSync(sandbox, { recursive: true, force: true }); + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + }); + + it("metadata is correct", () => { + expect(pi.name).toBe("pi"); + expect(pi.displayName).toBe("Pi"); + expect(typeof pi.spawn).toBe("function"); + expect(typeof pi.install).toBe("function"); + }); + + it("configure (no apiKey) throws AUTH_REQUIRED", async () => { + await expect(pi.configure({})).rejects.toMatchObject({ + code: "AUTH_REQUIRED", + }); + }); + + it("configure with apiKey writes the default compat URL into models.json", async () => { + await pi.configure({ apiKey: "op_live_test" }); + const models = readModels(sandbox); + expect(models.providers?.opper?.baseUrl).toBe( + "https://api.opper.ai/v3/compat", + ); + expect(models.providers?.opper?.apiKey).toBe("op_live_test"); + }); + + it("spawn writes routing.baseUrl (the session URL) into models.json before launching", async () => { + spawnSyncMock.mockReturnValue({ status: 0 }); + + const code = await pi.spawn!(["chat"], ROUTING); + expect(code).toBe(0); + + const models = readModels(sandbox); + expect(models.providers?.opper?.baseUrl).toBe(SESSION_URL); + expect(models.providers?.opper?.apiKey).toBe("op_live_run"); + + const raw = readFileSync( + join(sandbox, ".pi", "agent", "models.json"), + "utf8", + ); + expect(raw).not.toContain('"baseUrl": "https://api.opper.ai/v3/compat"'); + }); + + it("spawn auto-injects --provider opper and --model when user doesn't pass --model", async () => { + spawnSyncMock.mockReturnValue({ status: 0 }); + await pi.spawn!([], ROUTING); + const call = spawnSyncMock.mock.calls[0]!; + expect(call[0]).toBe("pi"); + expect(call[1]).toEqual([ + "--provider", + "opper", + "--model", + "claude-opus-4-7", + ]); + }); + + it("spawn does not auto-inject --model when user already passes one", async () => { + spawnSyncMock.mockReturnValue({ status: 0 }); + await pi.spawn!(["--model", "claude-haiku-4-5"], ROUTING); + const call = spawnSyncMock.mock.calls[0]!; + expect(call[1]).toEqual(["--provider", "opper", "--model", "claude-haiku-4-5"]); + }); + + it("spawn propagates non-zero exit codes", async () => { + spawnSyncMock.mockReturnValue({ status: 2 }); + const code = await pi.spawn!([], ROUTING); + expect(code).toBe(2); + }); + + it("unconfigure removes the opper provider", async () => { + await pi.configure({ apiKey: "op_live_test" }); + expect(existsSync(join(sandbox, ".pi", "agent", "models.json"))).toBe(true); + await pi.unconfigure(); + const models = readModels(sandbox); + expect(models.providers?.opper).toBeUndefined(); + }); +}); diff --git a/test/cli/agents.test.ts b/test/cli/agents.test.ts new file mode 100644 index 0000000..de61cfb --- /dev/null +++ b/test/cli/agents.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { collectTagPairs } from "../../src/cli/agents.js"; + +describe("collectTagPairs", () => { + it("accepts a key=value pair and returns it merged into the accumulator", () => { + const result = collectTagPairs("customer=acme", {}); + expect(result).toEqual({ customer: "acme" }); + }); + + it("merges into an existing accumulator without mutating it", () => { + const acc = { team: "eu" }; + const result = collectTagPairs("customer=acme", acc); + expect(result).toEqual({ team: "eu", customer: "acme" }); + // The previous accumulator is left untouched (Commander relies on the + // returned value being the new accumulator). + expect(acc).toEqual({ team: "eu" }); + }); + + it("rejects a value with no '=' separator", () => { + expect(() => collectTagPairs("nokey", {})).toThrow(/expects key=value/); + }); + + it("rejects an empty key (=value form)", () => { + expect(() => collectTagPairs("=value", {})).toThrow(/expects key=value/); + }); + + it("rejects the multi-pair shorthand (key=value,key=value)", () => { + expect(() => + collectTagPairs("team=eu,customer=acme", {}), + ).toThrow(/multiple pairs/); + }); + + it("preserves a plain comma inside a value", () => { + expect(collectTagPairs("customer=Acme, Inc", {})).toEqual({ + customer: "Acme, Inc", + }); + }); + + it("rejects a duplicate key on a second call", () => { + const acc = collectTagPairs("team=eu", {}); + expect(() => collectTagPairs("team=us", acc)).toThrow( + /"team" specified twice/, + ); + }); + + it("preserves '=' inside the value", () => { + const result = collectTagPairs("filter=a=b", {}); + expect(result).toEqual({ filter: "a=b" }); + }); +}); diff --git a/test/commands/launch.test.ts b/test/commands/launch.test.ts index 45454e0..f54c90a 100644 --- a/test/commands/launch.test.ts +++ b/test/commands/launch.test.ts @@ -25,6 +25,19 @@ vi.mock("../../src/agents/registry.js", () => ({ const loginMock = vi.fn(); vi.mock("../../src/commands/login.js", () => ({ loginCommand: loginMock })); +const apiGetMock = vi.fn().mockResolvedValue([]); +vi.mock("../../src/api/client.js", () => ({ + OpperApi: class { + get = apiGetMock; + }, +})); +vi.mock("../../src/api/resolve.js", () => ({ + resolveApiContext: vi.fn().mockResolvedValue({ + apiKey: "op_live_x", + baseUrl: "https://api.opper.ai", + }), +})); + const { launchCommand } = await import("../../src/commands/launch.js"); useTempOpperHome(); @@ -38,6 +51,8 @@ describe("launchCommand", () => { adapter.install.mockReset(); adapter.spawn.mockReset(); loginMock.mockReset(); + apiGetMock.mockClear(); + apiGetMock.mockResolvedValue([]); }); it("throws AGENT_NOT_FOUND when the adapter name is unknown", async () => { @@ -149,6 +164,95 @@ describe("launchCommand", () => { expect(code).toBe(-1); }); + it("includes a session prefix in routing.baseUrl with no tags", async () => { + await setSlot("default", { apiKey: "op_live_x" }); + adapter.detect.mockResolvedValue({ installed: true }); + adapter.spawn.mockResolvedValue(0); + + await launchCommand({ agent: "hermes", key: "default" }); + + const arg = adapter.spawn.mock.calls[0][1] as { baseUrl: string }; + expect(arg.baseUrl).toMatch( + /^https:\/\/api\.opper\.ai\/v3\/session\/sess_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); + + it("appends --tag pairs to routing.baseUrl in alphabetical order", async () => { + await setSlot("default", { apiKey: "op_live_x" }); + adapter.detect.mockResolvedValue({ installed: true }); + adapter.spawn.mockResolvedValue(0); + + await launchCommand({ + agent: "hermes", + key: "default", + tags: { team: "eu", customer: "acme" }, // intentionally unsorted + }); + + const arg = adapter.spawn.mock.calls[0][1] as { baseUrl: string }; + expect(arg.baseUrl).toMatch( + /\/v3\/session\/sess_[0-9a-f-]{36}\/customer:acme\/team:eu$/, + ); + }); + + it("rejects invalid --tag keys before spawning", async () => { + await setSlot("default", { apiKey: "op_live_x" }); + adapter.detect.mockResolvedValue({ installed: true }); + + await expect( + launchCommand({ + agent: "hermes", + key: "default", + tags: { "1bad": "v" }, + }), + ).rejects.toThrow(/invalid tag key/); + expect(adapter.spawn).not.toHaveBeenCalled(); + }); + + it("respects slot.baseUrl when set", async () => { + await setSlot("default", { apiKey: "op_live_x", baseUrl: "https://staging.opper.ai" }); + adapter.detect.mockResolvedValue({ installed: true }); + adapter.spawn.mockResolvedValue(0); + + await launchCommand({ agent: "hermes", key: "default" }); + + const arg = adapter.spawn.mock.calls[0][1] as { baseUrl: string }; + expect(arg.baseUrl.startsWith("https://staging.opper.ai/v3/session/sess_")).toBe(true); + }); + + it("queries /v2/analytics/usage with session_id and no date window", async () => { + await setSlot("default", { apiKey: "op_live_x" }); + adapter.detect.mockResolvedValue({ installed: true }); + // Make sure the session is long enough to pass the durationMs >= 1500 guard. + adapter.spawn.mockImplementation(async () => { + await new Promise((r) => setTimeout(r, 1600)); + return 0; + }); + + await launchCommand({ agent: "hermes", key: "default" }); + + // The first /v2/analytics/usage call is the summary. + const usageCall = apiGetMock.mock.calls.find( + ([path]) => path === "/v2/analytics/usage", + ); + expect(usageCall).toBeDefined(); + const [, query] = usageCall!; + expect(query.session_id).toMatch(/^sess_[0-9a-f-]{36}$/); + expect(query.granularity).toBe("minute"); + expect(query.group_by).toBe("model"); + expect(query).not.toHaveProperty("from_date"); + expect(query).not.toHaveProperty("to_date"); + }); + + it("skips the summary for very short sessions", async () => { + await setSlot("default", { apiKey: "op_live_x" }); + adapter.detect.mockResolvedValue({ installed: true }); + adapter.spawn.mockResolvedValue(0); // exits immediately, < 1500ms + + await launchCommand({ agent: "hermes", key: "default" }); + + expect(apiGetMock).not.toHaveBeenCalled(); + }); + it("rejects launching a configure-only adapter", async () => { const editorAdapter = { name: "editor-only", diff --git a/test/util/session-url.test.ts b/test/util/session-url.test.ts new file mode 100644 index 0000000..de273be --- /dev/null +++ b/test/util/session-url.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { + newSessionId, + buildSessionBaseUrl, + validateTags, +} from "../../src/util/session-url.js"; + +describe("session-url", () => { + it("newSessionId returns sess_", () => { + const id = newSessionId(); + expect(id).toMatch( + /^sess_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); + + it("buildSessionBaseUrl returns host/v3/session/ with no tags", () => { + const url = buildSessionBaseUrl("https://api.opper.ai", "sess_abc", {}); + expect(url).toBe("https://api.opper.ai/v3/session/sess_abc"); + }); + + it("buildSessionBaseUrl appends tag pairs in stable order", () => { + const url = buildSessionBaseUrl("https://api.opper.ai", "sess_abc", { + customer: "acme", + team: "eu", + }); + expect(url).toBe( + "https://api.opper.ai/v3/session/sess_abc/customer:acme/team:eu", + ); + }); + + it("buildSessionBaseUrl strips trailing slashes from the host", () => { + const url = buildSessionBaseUrl( + "https://api.opper.ai/", + "sess_abc", + {}, + ); + expect(url).toBe("https://api.opper.ai/v3/session/sess_abc"); + }); + + it("buildSessionBaseUrl percent-encodes values", () => { + const url = buildSessionBaseUrl("https://api.opper.ai", "sess_abc", { + team: "eu/west", + }); + expect(url).toBe( + "https://api.opper.ai/v3/session/sess_abc/team:eu%2Fwest", + ); + }); + + it("validateTags rejects an invalid key", () => { + expect(() => validateTags({ "1bad": "v" })).toThrow(/invalid tag key/); + }); + + it("validateTags rejects opper.* keys", () => { + expect(() => validateTags({ "opper.cost": "1" })).toThrow(/reserved/); + }); + + it("validateTags rejects Opper.* keys (case-insensitive)", () => { + expect(() => validateTags({ "Opper.cost": "1" })).toThrow(/reserved/); + }); + + it("validateTags rejects oversized values", () => { + const big = "x".repeat(257); + expect(() => validateTags({ k: big })).toThrow(/value too long/); + }); + + it("validateTags rejects > 8 pairs", () => { + const tags: Record = {}; + for (let i = 0; i < 9; i++) tags[`k${i}`] = "v"; + expect(() => validateTags(tags)).toThrow(/too many tags/); + }); +});