diff --git a/data/hermes-opper-plugin/__init__.py b/data/hermes-opper-plugin/__init__.py new file mode 100644 index 0000000..eb77c57 --- /dev/null +++ b/data/hermes-opper-plugin/__init__.py @@ -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, ) + X-Opper-Parent-Span-Id: uuid5(NS, ) (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, +)) diff --git a/data/hermes-opper-plugin/plugin.yaml b/data/hermes-opper-plugin/plugin.yaml new file mode 100644 index 0000000..bcc89c5 --- /dev/null +++ b/data/hermes-opper-plugin/plugin.yaml @@ -0,0 +1,4 @@ +name: opper +kind: model-provider +version: 1.0.0 +description: Opper — per-session trace grouping + provider-affinity headers diff --git a/src/agents/hermes.ts b/src/agents/hermes.ts index 29029d6..17aebf3 100644 --- a/src/agents/hermes.ts +++ b/src/agents/hermes.ts @@ -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, @@ -83,7 +84,7 @@ async function unconfigure(): Promise { * 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 { @@ -98,31 +99,31 @@ async function writeOpperConfig(routing: OpperRouting): Promise { ? ((parse(await readFile(path, "utf8")) as Record) ?? {}) : {}; 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 `/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 `/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> = {}; for (const m of PICKER_MODELS) opperModels[m.id] = {}; const providers = (existing.providers as Record | undefined) ?? {}; providers.opper = { name: "Opper", base_url: routing.baseUrl, - key_env: "OPENAI_API_KEY", + key_env: "OPPER_API_KEY", models: opperModels, }; existing.providers = providers; @@ -130,15 +131,32 @@ async function writeOpperConfig(routing: OpperRouting): Promise { 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 { + 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 { 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; diff --git a/test/agents/hermes.test.ts b/test/agents/hermes.test.ts index 174fe15..af8ceec 100644 --- a/test/agents/hermes.test.ts +++ b/test/agents/hermes.test.ts @@ -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); @@ -119,7 +119,7 @@ describe("hermes adapter — spawn (isolated HERMES_HOME)", () => { model?: Record; }; expect(written.model).toEqual({ - provider: "custom", + provider: "opper", base_url: "https://api.opper.ai/v3/compat", default: "claude-opus-4-7", }); @@ -127,7 +127,7 @@ describe("hermes adapter — spawn (isolated HERMES_HOME)", () => { 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); @@ -136,7 +136,7 @@ describe("hermes adapter — spawn (isolated HERMES_HOME)", () => { const [, , runOpts] = runMock.mock.calls[0]!; const env = (runOpts as { env: Record }).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 () => { @@ -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", }); @@ -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 } }; }; + // 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"); @@ -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);