Skip to content
Closed
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
3 changes: 3 additions & 0 deletions docs/pages/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ mounting, and network policies all assume Kubernetes sandboxes.
| Claude Code | Passes the Anthropic-shaped content through directly. |
| Codex / pi-mono | Extracts text blocks for CLIs that accept a plain prompt. |

The OpenRouter selector uses the Codex adapter with an OpenRouter provider
configuration, so clients still see the same durable execution protocol.

The pod receives the prompt files, CLI command, internal API URL, proxy CA, and
proxy settings. It does not need Kubernetes credentials or long-lived
third-party API keys.
Expand Down
12 changes: 10 additions & 2 deletions docs/pages/deploying-in-production.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ Store one secret per enabled harness credential:
| Harness | API value | Slack selector | Credential to store | Upstream |
|---------|-----------|----------------|---------------------|----------|
| Codex default | `codex` | none or `--codex` | `OPENAI_API_KEY` | `api.openai.com` |
| OpenRouter via Codex | `openrouter` | `--openrouter` | `OPENROUTER_API_KEY` | `openrouter.ai` |
| Amp | `amp` | `--amp` | `AMP_API_KEY` | `ampcode.com` |
| Claude Code | `claude-code` | `--claude` | `ANTHROPIC_API_KEY` | `api.anthropic.com` |
| pi-mono | `pi-mono` | `--pi` | `ANTHROPIC_API_KEY` | `api.anthropic.com` |
Expand All @@ -91,7 +92,13 @@ headers the secret is bound to.

When `ironProxy.secretSource` is `onepassword`, [iron-proxy](https://docs.iron.sh) resolves these values
from `op://$OP_VAULT/<SECRET_NAME>/credential`. For example, store the default
Codex credential in a 1Password item named `OPENAI_API_KEY`.
Codex credential in a 1Password item named `OPENAI_API_KEY`, and store the
OpenRouter credential in a 1Password item named `OPENROUTER_API_KEY` if you
enable the `--openrouter` selector.

The OpenRouter selector runs the Codex harness against the configured
OpenRouter provider. It defaults to `openrouter/auto`; set `OPENROUTER_MODEL`
on the API deployment if you want a fixed OpenRouter model slug.

Whatever source you pick, the vault is shared across the whole deployment,
so any thread can use any configured credential. Per-user and per-channel
Expand Down Expand Up @@ -323,7 +330,8 @@ reply with exactly PONG
```

Slack messages without a harness flag use Codex. Use `--amp`, `--claude`,
`--codex`, or `--pi` only when you want to select a specific harness.
`--codex`, `--openrouter`, or `--pi` only when you want to select a specific
harness.

Inspect sandbox pods with the labels Centaur actually sets:

Expand Down
2 changes: 1 addition & 1 deletion docs/pages/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Onboard me to Centaur locally. Use https://centaur.run/llms-full.txt and follow
<li>
<a href="/architecture#execution-path">
<strong>Harness agnostic.</strong>
<span>Use Amp, Codex, Claude Code, pi-mono, or your own CLI harness with the same durable execution model.</span>
<span>Use Amp, Codex, Codex through OpenRouter, Claude Code, pi-mono, or your own CLI harness with the same durable execution model.</span>
</a>
</li>
<li>
Expand Down
9 changes: 6 additions & 3 deletions docs/pages/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,16 @@ enabled in `values.dev.yaml`; use a real token if you want to test Slack.
Postgres on startup, so it must exist before `just up`.

Application-level model and tool secrets, such as `OPENAI_API_KEY`,
`ANTHROPIC_API_KEY`, `AMP_API_KEY`, and `GITHUB_TOKEN`, should live in
`OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, `AMP_API_KEY`, and `GITHUB_TOKEN`,
should live in
1Password or the configured [iron-proxy](https://docs.iron.sh) secret source. Sandboxes receive
placeholder values and [iron-proxy](https://docs.iron.sh) injects the real credentials only on approved
outbound requests.

The default harness is `codex`, so `OPENAI_API_KEY` must exist in the configured
secret source before Slack agent turns can complete. Use explicit harness
selectors only when you want a non-default harness such as Amp or Claude Code.
selectors only when you want a non-default harness such as Amp, Claude Code, or
OpenRouter.

## 3. Boot the stack

Expand Down Expand Up @@ -156,7 +158,8 @@ Mention the bot in a test channel where the Slack app is installed:
```

Slack messages without a harness flag use Codex. Add a selector such as
`--amp`, `--claude`, or `--pi` only when you want to override the default.
`--amp`, `--claude`, `--openrouter`, or `--pi` only when you want to override
the default.

If Slack receives the mention but no agent runs, inspect Slackbot logs:

Expand Down
1 change: 1 addition & 0 deletions docs/pages/secrets/onepassword.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ Store enabled harness credentials the same way:
| Credential | Used for |
|------------|----------|
| `OPENAI_API_KEY` | Codex default |
| `OPENROUTER_API_KEY` | OpenRouter via Codex |
| `AMP_API_KEY` | Amp |
| `ANTHROPIC_API_KEY` | Claude Code and pi-mono |

Expand Down
3 changes: 3 additions & 0 deletions docs/public/md/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ mounting, and network policies all assume Kubernetes sandboxes.
| Claude Code | Passes the Anthropic-shaped content through directly. |
| Codex / pi-mono | Extracts text blocks for CLIs that accept a plain prompt. |

The OpenRouter selector uses the Codex adapter with an OpenRouter provider
configuration, so clients still see the same durable execution protocol.

The pod receives the prompt files, CLI command, internal API URL, proxy CA, and
proxy settings. It does not need Kubernetes credentials or long-lived
third-party API keys.
Expand Down
17 changes: 15 additions & 2 deletions docs/public/md/deploying-in-production.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ Store one secret per enabled harness credential:
| Harness | API value | Slack selector | Credential to store | Upstream |
|---------|-----------|----------------|---------------------|----------|
| Codex default | `codex` | none or `--codex` | `OPENAI_API_KEY` | `api.openai.com` |
| OpenRouter via Codex | `openrouter` | `--openrouter` | `OPENROUTER_API_KEY` | `openrouter.ai` |
| Amp | `amp` | `--amp` | `AMP_API_KEY` | `ampcode.com` |
| Claude Code | `claude-code` | `--claude` | `ANTHROPIC_API_KEY` | `api.anthropic.com` |
| pi-mono | `pi-mono` | `--pi` | `ANTHROPIC_API_KEY` | `api.anthropic.com` |
Expand All @@ -91,7 +92,17 @@ headers the secret is bound to.

When `ironProxy.secretSource` is `onepassword`, [iron-proxy](https://docs.iron.sh) resolves these values
from `op://$OP_VAULT/<SECRET_NAME>/credential`. For example, store the default
Codex credential in a 1Password item named `OPENAI_API_KEY`.
Codex credential in a 1Password item named `OPENAI_API_KEY`, and store the
OpenRouter credential in a 1Password item named `OPENROUTER_API_KEY` if you
enable the `--openrouter` selector.

The OpenRouter selector runs the Codex harness against the configured
OpenRouter provider. It defaults to `openrouter/auto`; set `OPENROUTER_MODEL`
on the API deployment if you want a fixed default slug, or choose a model for a
new runtime with `--openrouter --model anthropic/claude-sonnet-4.5` in Slack.
API clients can pass the same model slug as `model` on `/agent/spawn` or on the
single-call `/agent/execute` convenience path when no `assignment_generation`
is supplied.

Whatever source you pick, the vault is shared across the whole deployment,
so any thread can use any configured credential. Per-user and per-channel
Expand Down Expand Up @@ -233,7 +244,9 @@ reply with exactly PONG
```

Slack messages without a harness flag use Codex. Use `--amp`, `--claude`,
`--codex`, or `--pi` only when you want to select a specific harness.
`--codex`, `--openrouter`, or `--pi` only when you want to select a specific
harness. Add `--model <provider/model>` with `--openrouter` to pin the new
runtime to a specific OpenRouter model.

Inspect sandbox pods with the labels Centaur actually sets:

Expand Down
2 changes: 1 addition & 1 deletion docs/public/md/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Onboard me to Centaur locally. Use https://centaur.run/llms-full.txt and follow
<li>
<a href="/architecture#execution-path">
<strong>Harness agnostic.</strong>
<span>Use Amp, Codex, Claude Code, pi-mono, or your own CLI harness with the same durable execution model.</span>
<span>Use Amp, Codex, Codex through OpenRouter, Claude Code, pi-mono, or your own CLI harness with the same durable execution model.</span>
</a>
</li>
<li>
Expand Down
15 changes: 9 additions & 6 deletions docs/public/md/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,15 @@ enabled in `values.dev.yaml`; use a real token if you want to test Slack.
Postgres on startup, so it must exist before `just up`.

Application-level model and tool secrets, such as `OPENAI_API_KEY`,
`ANTHROPIC_API_KEY`, `AMP_API_KEY`, and `GITHUB_TOKEN`, should live in
1Password or the configured [iron-proxy](https://docs.iron.sh) secret source. Sandboxes receive
placeholder values and [iron-proxy](https://docs.iron.sh) injects the real credentials only on approved
outbound requests.
`OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, `AMP_API_KEY`, and `GITHUB_TOKEN`,
should live in 1Password or the configured [iron-proxy](https://docs.iron.sh)
secret source. Sandboxes receive placeholder values and [iron-proxy](https://docs.iron.sh) injects the
real credentials only on approved outbound requests.

The default harness is `codex`, so `OPENAI_API_KEY` must exist in the configured
secret source before Slack agent turns can complete. Use explicit harness
selectors only when you want a non-default harness such as Amp or Claude Code.
selectors only when you want a non-default harness such as Amp, Claude Code, or
OpenRouter.

## 3. Boot the stack

Expand Down Expand Up @@ -156,7 +157,9 @@ Mention the bot in a test channel where the Slack app is installed:
```

Slack messages without a harness flag use Codex. Add a selector such as
`--amp`, `--claude`, or `--pi` only when you want to override the default.
`--amp`, `--claude`, `--openrouter`, or `--pi` only when you want to override
the default. For OpenRouter, add `--model <provider/model>` when you want the
new runtime to use a specific model instead of `openrouter/auto`.

If Slack receives the mention but no agent runs, inspect Slackbot logs:

Expand Down
1 change: 1 addition & 0 deletions docs/public/md/secrets/onepassword.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ Store enabled harness credentials the same way:
| Credential | Used for |
|------------|----------|
| `OPENAI_API_KEY` | Codex default |
| `OPENROUTER_API_KEY` | OpenRouter via Codex |
| `AMP_API_KEY` | Amp |
| `ANTHROPIC_API_KEY` | Claude Code and pi-mono |

Expand Down
7 changes: 7 additions & 0 deletions harness/codex/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,12 @@ job_max_runtime_seconds = 1800
[tools]
view_image = true

[model_providers.openrouter]
name = "OpenRouter"
base_url = "https://openrouter.ai/api/v1"
env_key = "OPENROUTER_API_KEY"
wire_api = "responses"
requires_openai_auth = false

[projects."/"]
trust_level = "trusted"
26 changes: 24 additions & 2 deletions packages/api-client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface SpawnOptions {
spawnId?: string;
harness?: string;
engine?: string;
model?: string;
personaId?: string;
agentsMdOverride?: string;
}
Expand All @@ -30,6 +31,7 @@ export interface SpawnResult {
trace_id?: string;
assignment_state: string;
assignment_generation: number;
model?: string | null;
persona_id?: string | null;
prompt_ref?: string | null;
effective_agents_md_sha256?: string | null;
Expand All @@ -46,9 +48,8 @@ export interface MessageOptions {
metadata?: Record<string, unknown>;
}

export interface ExecuteOptions {
interface ExecuteBaseOptions {
threadKey: string;
assignmentGeneration: number;
executeId?: string;
harness?: string;
platform?: string;
Expand All @@ -57,6 +58,22 @@ export interface ExecuteOptions {
delivery?: Record<string, unknown>;
}

export type ExecuteOptions =
| (ExecuteBaseOptions & {
assignmentGeneration: number;
message?: never;
engine?: never;
model?: never;
personaId?: never;
})
| (ExecuteBaseOptions & {
assignmentGeneration?: undefined;
message: string;
engine?: string;
model?: string;
personaId?: string;
});

export interface ExecutionAccepted {
ok: boolean;
execution_id: string;
Expand Down Expand Up @@ -143,6 +160,7 @@ export class CentaurClient {
spawn_id: opts.spawnId,
harness: opts.harness,
engine: opts.engine,
model: opts.model,
persona_id: opts.personaId,
agents_md_override: opts.agentsMdOverride,
});
Expand Down Expand Up @@ -175,6 +193,10 @@ export class CentaurClient {
assignment_generation: opts.assignmentGeneration,
execute_id: opts.executeId,
harness: opts.harness,
message: opts.message,
engine: opts.engine,
model: opts.model,
persona_id: opts.personaId,
platform: opts.platform,
user_id: opts.userId,
metadata: opts.metadata,
Expand Down
40 changes: 40 additions & 0 deletions packages/api-client/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,46 @@ describe("CentaurClient", () => {
);
});

it("posts model selectors for spawn and one-shot execute", async () => {
const client = new CentaurClient({
apiUrl: "http://api.local",
apiKey: "test-key",
});
const postMock = vi.spyOn(client.http, "post").mockResolvedValue({ data: { ok: true } });

await client.spawn({
threadKey: "thread-1",
harness: "openrouter",
model: "anthropic/claude-sonnet-4.5",
});
await client.execute({
threadKey: "thread-2",
harness: "openrouter",
model: "google/gemini-2.5-pro",
message: "review this",
});

expect(postMock).toHaveBeenNthCalledWith(
1,
"/agent/spawn",
expect.objectContaining({
thread_key: "thread-1",
harness: "openrouter",
model: "anthropic/claude-sonnet-4.5",
}),
);
expect(postMock).toHaveBeenNthCalledWith(
2,
"/agent/execute",
expect.objectContaining({
thread_key: "thread-2",
harness: "openrouter",
model: "google/gemini-2.5-pro",
message: "review this",
}),
);
});

it("throws useful errors for non-OK event stream responses", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response(
"upstream unavailable",
Expand Down
40 changes: 40 additions & 0 deletions packages/api-client/test/harness-events.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";

import { normalizeHarnessEvent } from "@centaur/harness-events";

describe("normalizeHarnessEvent", () => {
it("normalizes OpenRouter thread starts through the Codex event path", () => {
expect(
normalizeHarnessEvent("openrouter", {
type: "thread.started",
thread_id: "thread-or",
}),
).toEqual([{ type: "system", subtype: "init", session_id: "thread-or" }]);
});

it("normalizes OpenRouter turn completion usage through the Codex event path", () => {
expect(
normalizeHarnessEvent("openrouter", {
type: "turn.completed",
model: "openai/gpt-4o-mini",
usage: { input_tokens: 3, output_tokens: 5 },
}),
).toEqual([
{
type: "usage",
usage: { input_tokens: 3, output_tokens: 5 },
model: "openai/gpt-4o-mini",
authoritative: true,
},
]);
});

it("passes OpenRouter Codex item events through instead of treating them as amp events", () => {
const event = {
type: "item.completed",
item: { type: "agent_message", text: "hello" },
};

expect(normalizeHarnessEvent("openrouter", event)).toEqual([event]);
});
});
Loading