Skip to content
4 changes: 1 addition & 3 deletions src/agents/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -51,7 +49,7 @@ async function spawn(args: string[], routing: OpperRouting): Promise<number> {
// 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
Expand Down
72 changes: 43 additions & 29 deletions src/agents/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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");
Expand Down Expand Up @@ -73,16 +76,17 @@ async function isConfigured(): Promise<boolean> {
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<void> {
async function writeOpperBlock(baseUrl: string): Promise<void> {
const cfg = codexConfigPath();
let existing = "";
if (existsSync(cfg)) {
Expand All @@ -93,17 +97,26 @@ async function configure(): Promise<void> {
}
}
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<void> {
// 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<void> {
const cfg = codexConfigPath();
if (!existsSync(cfg)) return;
Expand All @@ -128,8 +141,9 @@ function hasProfileArg(args: string[]): boolean {
}

async function spawn(args: string[], routing: OpperRouting): Promise<number> {
// 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,
Expand Down
16 changes: 10 additions & 6 deletions src/agents/openclaw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,17 @@ async function writeConfig(data: ModelsFile): Promise<void> {
await writeFile(cfg, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
}

async function setOpperProvider(apiKey: string, launchModel: string): Promise<void> {
async function setOpperProvider(
apiKey: string,
launchModel: string,
baseUrl: string,
): Promise<void> {
const cfg = await readConfig();
cfg.providers = cfg.providers ?? {};
cfg.providers[PROVIDER_KEY] = {
api: "openai-completions",
apiKey,
baseUrl: OPPER_COMPAT_URL,
baseUrl,
models: [
{
id: launchModel,
Expand Down Expand Up @@ -117,7 +121,7 @@ async function configure(opts: ConfigureOptions): Promise<void> {
"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<void> {
Expand All @@ -129,9 +133,9 @@ async function unconfigure(): Promise<void> {
}

async function spawn(args: string[], routing: OpperRouting): Promise<number> {
// 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
Expand Down
34 changes: 34 additions & 0 deletions src/agents/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,42 @@ async function unconfigure(): Promise<void> {
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/<sid>/<tags...> 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<void> {
const cfg = opencodeConfigPath(location);
if (!existsSync(cfg)) return;
let parsed: {
provider?: Record<string, { options?: Record<string, unknown> }>;
[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<number> {
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
Expand All @@ -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,
Expand Down
16 changes: 10 additions & 6 deletions src/agents/pi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,17 @@ async function writeConfig(data: PiModelsFile): Promise<void> {
* Idempotently install our `opper` provider entry. Other providers in the
* same file (ollama, etc.) are preserved.
*/
async function setOpperProvider(apiKey: string, launchModel: string): Promise<void> {
async function setOpperProvider(
apiKey: string,
launchModel: string,
baseUrl: string,
): Promise<void> {
const cfg = await readConfig();
cfg.providers = cfg.providers ?? {};
cfg.providers[PROVIDER_KEY] = {
api: "openai-completions",
apiKey,
baseUrl: OPPER_COMPAT_URL,
baseUrl,
models: [
{
id: launchModel,
Expand Down Expand Up @@ -115,7 +119,7 @@ async function configure(opts: ConfigureOptions): Promise<void> {
"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<void> {
Expand All @@ -127,9 +131,9 @@ async function unconfigure(): Promise<void> {
}

async function spawn(args: string[], routing: OpperRouting): Promise<number> {
// 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
Expand Down
43 changes: 42 additions & 1 deletion src/cli/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>,
): Record<string, string> {
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")
Expand Down Expand Up @@ -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 <pair>",
"attach metadata as key=value (repeatable). Stored as a tag on every generation in this launch.",
collectTagPairs,
{} as Record<string, string>,
)
.allowUnknownOption(true)
.allowExcessArguments(true)
.action(
async (
agentName: string,
cmdOpts: { model?: string; install?: boolean; project?: boolean },
cmdOpts: {
model?: string;
install?: boolean;
project?: boolean;
tag?: Record<string, string>;
},
cmd,
) => {
const args = (cmd.args as string[]).slice(1);
Expand All @@ -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);
Expand Down
Loading
Loading