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
69 changes: 69 additions & 0 deletions data/hermes-opper-plugin/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Opper provider profile for `opper launch hermes`.

Shipped by the Opper CLI into the isolated HERMES_HOME at launch (the CLI writes
`model.provider: opper`). On every request it emits two headers:

X-Opper-Trace-Id: uuid5(NS, <session key>)
X-Opper-Parent-Span-Id: uuid5(NS, <session key>) (same value)

X-Opper-Trace-Id groups a session's calls into one trace and drives provider
affinity (sticky provider for prompt-cache reuse across turns). Setting
X-Opper-Parent-Span-Id to the same value makes Opper auto-create a single
"session" root span and nest each turn under it, so the trace renders as one
tree instead of N sibling roots.

The session key is Hermes' own session_id when available (it rotates per
subagent and per /reset), falling back to a per-process id so one-shot runs
still share one trace.
"""

import uuid
from typing import Any

from providers import register_provider
from providers.base import ProviderProfile

# Fixed namespace -> deterministic, valid-UUID trace ids (Opper validates the
# trace_id as a UUID). The same session key always maps to the same trace id.
_NS = uuid.UUID("3f2504e0-4f89-41d3-9a0c-0305e82c3301")

# Fallback session key, stable for the lifetime of this Hermes process (one
# `opper launch`). Used when Hermes passes no session_id (e.g. one-shot `-z`
# runs) so every call in the launch still shares one trace + session root.
_PROCESS_SESSION = str(uuid.uuid4())


class OpperProfile(ProviderProfile):
def build_api_kwargs_extras(
self,
*,
session_id: str | None = None,
**ctx: Any,
) -> tuple[dict[str, Any], dict[str, Any]]:
sid = session_id or _PROCESS_SESSION
tid = str(uuid.uuid5(_NS, sid))
top: dict[str, Any] = {
"extra_headers": {
"X-Opper-Trace-Id": tid,
"X-Opper-Parent-Span-Id": tid,
}
}
return {}, top

def fetch_models(self, *, api_key: str | None = None, timeout: float = 8.0):
if not self.base_url:
return None
return super().fetch_models(api_key=api_key, timeout=timeout)


register_provider(OpperProfile(
name="opper",
# Read the api key from OPPER_API_KEY (the env the CLI exports at spawn). A
# dedicated name (vs OPENAI_API_KEY) avoids clobbering the user's real
# OpenAI key for any other provider in the launched Hermes. Without a
# declared key env Hermes sends a "no-key-required" placeholder and Opper
# rejects it with 401.
env_vars=("OPPER_API_KEY",),
base_url="",
default_max_tokens=65536,
))
4 changes: 4 additions & 0 deletions data/hermes-opper-plugin/plugin.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
name: opper
kind: model-provider
version: 1.0.0
description: Opper — per-session trace grouping + provider-affinity headers
50 changes: 34 additions & 16 deletions src/agents/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { run } from "../util/run.js";
import { opperHome } from "../auth/paths.js";
import { OpperError } from "../errors.js";
import { PICKER_MODELS } from "../config/models.js";
import { assetPath } from "../util/assets.js";
import type {
AgentAdapter,
DetectResult,
Expand Down Expand Up @@ -83,7 +84,7 @@ async function unconfigure(): Promise<void> {
* Writes the minimum config Hermes needs to talk to Opper. Hermes (since
* v0.5+) refuses to honour `OPENAI_BASE_URL` from the environment — the
* base URL must live in config.yaml — so we bake it into our isolated
* HERMES_HOME before each launch. The api key is passed via OPENAI_API_KEY
* HERMES_HOME before each launch. The api key is passed via OPPER_API_KEY
* env at spawn time so the secret never lands on disk.
*/
async function writeOpperConfig(routing: OpperRouting): Promise<void> {
Expand All @@ -98,47 +99,64 @@ async function writeOpperConfig(routing: OpperRouting): Promise<void> {
? ((parse(await readFile(path, "utf8")) as Record<string, unknown>) ?? {})
: {};
existing.model = {
provider: "custom",
provider: "opper",
base_url: routing.baseUrl,
default: routing.model,
};

// Register Opper as a named provider so Hermes' `/model` picker shows
// "Opper (N models)" alongside the built-in providers. Without this the
// picker only enumerates first-class providers (OpenRouter, Copilot,
// OpenAI…) with detected creds — our `model.provider: custom` route is
// invisible to it.
// `model.provider` and the providers entry must share the same key. Hermes
// resolves the request-time api key from the ACTIVE provider's `key_env`, so
// a mismatch (e.g. provider "custom" but config under "opper") leaves the
// active provider with no key and Hermes sends a "no-key-required" placeholder
// that Opper rejects with 401. The shipped `opper` provider plugin
// (writeOpperPlugin) registers a matching profile so this name resolves and
// the session/affinity headers are emitted; it also makes the `/model` picker
// show "Opper (N models)".
//
// We let Hermes auto-discover models from `<base_url>/v1/models` (the
// default behaviour) — same pattern Claude Code uses with our compat
// endpoint. The curated `models:` dict below is the fallback Hermes
// uses when discovery fails (network error, auth, etc.).
//
// `key_env: OPENAI_API_KEY` matches the env var we already export at
// spawn time, so no api key lands on disk.
// The curated `models:` dict is the fallback Hermes uses when live discovery
// from `<base_url>/v1/models` fails. `key_env: OPPER_API_KEY` matches the env
// we export at spawn, so no api key lands on disk. We use a dedicated env name
// (not OPENAI_API_KEY) so the launch never clobbers the user's real OpenAI key.
const opperModels: Record<string, Record<string, never>> = {};
for (const m of PICKER_MODELS) opperModels[m.id] = {};
const providers = (existing.providers as Record<string, unknown> | undefined) ?? {};
providers.opper = {
name: "Opper",
base_url: routing.baseUrl,
key_env: "OPENAI_API_KEY",
key_env: "OPPER_API_KEY",
models: opperModels,
};
existing.providers = providers;

await writeFile(path, stringify(existing), { mode: 0o600 });
}

/**
* Install the Opper provider plugin into the isolated HERMES_HOME. It registers
* the `opper` provider (the one `writeOpperConfig` selects) and emits the
* per-session `X-Opper-Trace-Id` / `X-Opper-Parent-Span-Id` headers that drive
* provider affinity and the session-root span tree. Rewritten each launch so
* CLI upgrades ship plugin changes.
*/
async function writeOpperPlugin(home: string): Promise<void> {
const dir = join(home, "plugins", "model-providers", "opper");
await mkdir(dir, { recursive: true });
for (const file of ["__init__.py", "plugin.yaml"]) {
const contents = await readFile(assetPath(join("hermes-opper-plugin", file)), "utf8");
await writeFile(join(dir, file), contents, { mode: 0o644 });
}
}

async function spawn(args: string[], routing: OpperRouting): Promise<number> {
await writeOpperConfig(routing);
await writeOpperPlugin(hermesHome());

const result = run("hermes", args, {
inherit: true,
env: {
...process.env,
HERMES_HOME: hermesHome(),
OPENAI_API_KEY: routing.apiKey,
OPPER_API_KEY: routing.apiKey,
},
});
return result.code;
Expand Down
29 changes: 23 additions & 6 deletions test/agents/hermes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ describe("hermes adapter — spawn (isolated HERMES_HOME)", () => {
else process.env.OPPER_HOME = prevOpperHome;
});

it("creates the Opper-managed hermes-home and writes a custom-provider config.yaml", async () => {
it("creates the Opper-managed hermes-home and writes an opper-provider config.yaml", async () => {
runMock.mockReturnValue({ code: 0, stdout: "", stderr: "" });

const code = await hermes.spawn!(["chat"], ROUTING);
Expand All @@ -119,15 +119,15 @@ describe("hermes adapter — spawn (isolated HERMES_HOME)", () => {
model?: Record<string, unknown>;
};
expect(written.model).toEqual({
provider: "custom",
provider: "opper",
base_url: "https://api.opper.ai/v3/compat",
default: "claude-opus-4-7",
});
// api_key intentionally NOT written to disk — it goes via env.
expect(written.model).not.toHaveProperty("api_key");
});

it("passes HERMES_HOME and OPENAI_API_KEY through to the hermes process env", async () => {
it("passes HERMES_HOME and OPPER_API_KEY through to the hermes process env", async () => {
runMock.mockReturnValue({ code: 0, stdout: "", stderr: "" });

await hermes.spawn!([], ROUTING);
Expand All @@ -136,7 +136,7 @@ describe("hermes adapter — spawn (isolated HERMES_HOME)", () => {
const [, , runOpts] = runMock.mock.calls[0]!;
const env = (runOpts as { env: Record<string, string> }).env;
expect(env.HERMES_HOME).toBe(join(sandbox, ".opper", "hermes-home"));
expect(env.OPENAI_API_KEY).toBe("op_live_test");
expect(env.OPPER_API_KEY).toBe("op_live_test");
});

it("preserves non-model settings already present in the Opper-managed config.yaml", async () => {
Expand Down Expand Up @@ -167,7 +167,7 @@ describe("hermes adapter — spawn (isolated HERMES_HOME)", () => {
toolsets?: unknown;
};
expect(after.model).toEqual({
provider: "custom",
provider: "opper",
base_url: "https://api.opper.ai/v3/compat",
default: "claude-opus-4-7",
});
Expand All @@ -183,10 +183,12 @@ describe("hermes adapter — spawn (isolated HERMES_HOME)", () => {
const written = parse(readFileSync(configPath, "utf8")) as {
providers?: { opper?: { name?: string; base_url?: string; key_env?: string; models?: Record<string, unknown> } };
};
// The provider block is keyed "opper" to match model.provider, so Hermes
// resolves the api key from this provider's key_env.
expect(written.providers?.opper).toBeDefined();
expect(written.providers?.opper?.name).toBe("Opper");
expect(written.providers?.opper?.base_url).toBe(SESSION_URL);
expect(written.providers?.opper?.key_env).toBe("OPENAI_API_KEY");
expect(written.providers?.opper?.key_env).toBe("OPPER_API_KEY");
const ids = Object.keys(written.providers?.opper?.models ?? {});
// Spot-check both ends: the curated 5 plus the 5 added later.
expect(ids).toContain("claude-opus-4-7");
Expand Down Expand Up @@ -216,6 +218,21 @@ describe("hermes adapter — spawn (isolated HERMES_HOME)", () => {
expect(after.providers?.opper?.base_url).toBe(SESSION_B);
});

it("installs the Opper provider plugin into HERMES_HOME on spawn", async () => {
runMock.mockReturnValue({ code: 0, stdout: "", stderr: "" });
await hermes.spawn!([], ROUTING);

const pluginDir = join(
sandbox, ".opper", "hermes-home", "plugins", "model-providers", "opper",
);
expect(existsSync(join(pluginDir, "__init__.py"))).toBe(true);
expect(existsSync(join(pluginDir, "plugin.yaml"))).toBe(true);
// The plugin emits the headers that drive session grouping + affinity.
const src = readFileSync(join(pluginDir, "__init__.py"), "utf8");
expect(src).toContain("X-Opper-Trace-Id");
expect(src).toContain("X-Opper-Parent-Span-Id");
});

it("propagates non-zero exit codes from run()", async () => {
runMock.mockReturnValue({ code: 2, stdout: "", stderr: "" });
const code = await hermes.spawn!([], ROUTING);
Expand Down