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
65 changes: 59 additions & 6 deletions crates/harness-server/src/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,48 @@ use crate::server::{BlocksCommand, BlocksState, parse_blocks_line_with_state, wr
use crate::util::write_value;
use crate::{AppServerRuntime, HarnessServerError, Result};

#[derive(Debug, Default)]
pub struct CodexHarnessServer;
#[derive(Debug, Clone, Copy)]
pub struct CodexHarnessServer {
fallback_model_provider: &'static str,
}

impl CodexHarnessServer {
pub fn codex() -> Self {
Self {
fallback_model_provider: "openai",
}
}

fn default_model(&self) -> Option<String> {
env::var("CODEX_MODEL")
.ok()
.or_else(|| env::var("OPENROUTER_MODEL").ok())
.map(|model| model.trim().to_owned())
.filter(|model| !model.is_empty())
}

fn model_provider_for(&self, model: Option<&str>) -> String {
env::var("CODEX_MODEL_PROVIDER")
.ok()
.map(|provider| provider.trim().to_owned())
.filter(|provider| !provider.is_empty())
.or_else(|| {
model
.map(str::trim)
.filter(|model| !model.is_empty())
.filter(|model| model.contains('/'))
.map(|_| "openrouter".to_string())
})
.or_else(|| {
env::var("OPENROUTER_MODEL")
.ok()
.map(|model| model.trim().to_owned())
.filter(|model| !model.is_empty())
.map(|_| "openrouter".to_string())
})
.unwrap_or_else(|| self.fallback_model_provider.to_string())
}
}

impl AppServerRuntime for CodexHarnessServer {
fn run_stdio(&self) -> Result<()> {
Expand Down Expand Up @@ -65,7 +105,7 @@ impl AppServerRuntime for CodexHarnessServer {
}
}

pub(crate) fn run_codex_blocks_server() -> Result<()> {
pub(crate) fn run_codex_blocks_server(config: CodexHarnessServer) -> Result<()> {
let mut codex = CodexJsonRpcChild::spawn()?;
let mut stdout = io::stdout().lock();
let mut request_id = 1_i64;
Expand Down Expand Up @@ -108,7 +148,11 @@ pub(crate) fn run_codex_blocks_server() -> Result<()> {
&mut thread_id,
input,
client_user_message_id,
model,
{
let model = model.or_else(|| config.default_model());
let model_provider = config.model_provider_for(model.as_deref());
(model, model_provider)
},
) {
let fallback_thread_id = thread_id.as_deref().unwrap_or("codex");
eprintln!("Codex blocks turn failed: {error:#}");
Expand Down Expand Up @@ -143,10 +187,16 @@ fn run_codex_user_turn<W: Write>(
thread_id: &mut Option<String>,
input: Vec<UserInput>,
client_user_message_id: Option<String>,
model: Option<String>,
model_and_provider: (Option<String>, String),
) -> Result<()> {
let (model, model_provider) = model_and_provider;
if thread_id.is_none() {
*thread_id = Some(start_or_resume_thread(codex, stdout, request_id)?);
*thread_id = Some(start_or_resume_thread(
codex,
stdout,
request_id,
&model_provider,
)?);
}
let current_thread_id = thread_id
.as_ref()
Expand Down Expand Up @@ -181,6 +231,7 @@ fn start_or_resume_thread<W: Write>(
codex: &mut CodexJsonRpcChild,
stdout: &mut W,
request_id: &mut i64,
model_provider: &str,
) -> Result<String> {
let cwd = env::current_dir()?.display().to_string();
let resume = env::var("CODEX_CONTINUE_THREAD_ID")
Expand All @@ -194,6 +245,7 @@ fn start_or_resume_thread<W: Write>(
"approvalPolicy": "never",
"approvalsReviewer": "user",
"sandbox": "danger-full-access",
"modelProvider": model_provider,
}),
)
} else {
Expand All @@ -205,6 +257,7 @@ fn start_or_resume_thread<W: Write>(
"approvalPolicy": "never",
"approvalsReviewer": "user",
"sandbox": "danger-full-access",
"modelProvider": model_provider,
"excludeTurns": false,
}),
)
Expand Down
4 changes: 2 additions & 2 deletions crates/harness-server/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ use crate::{HarnessServerError, Result};

pub fn server_for(kind: HarnessKind) -> Box<dyn AppServerRuntime> {
match kind {
HarnessKind::Codex => Box::new(CodexHarnessServer),
HarnessKind::Codex => Box::new(CodexHarnessServer::codex()),
HarnessKind::ClaudeCode => Box::new(AppServerNormalizer::new(ClaudeCodeHarness)),
HarnessKind::Amp => Box::new(AppServerNormalizer::new(AmpHarness)),
}
Expand All @@ -46,7 +46,7 @@ pub fn run_harness_server(kind: HarnessKind) -> Result<()> {

pub fn run_blocks_server(kind: HarnessKind) -> Result<()> {
match kind {
HarnessKind::Codex => crate::codex::run_codex_blocks_server(),
HarnessKind::Codex => crate::codex::run_codex_blocks_server(CodexHarnessServer::codex()),
HarnessKind::ClaudeCode => run_blocks_app_server(&ClaudeCodeHarness),
HarnessKind::Amp => run_blocks_app_server(&AmpHarness),
}
Expand Down
162 changes: 158 additions & 4 deletions crates/harness-server/tests/app_server_stdio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,119 @@ fn fake_claude_app_server_streams_codex_v2_notifications() {
assert_codex_v2_turn(&run.turn);
}

#[test]
fn fake_codex_blocks_mode_uses_openrouter_provider_when_model_is_configured() {
let fake_codex = temp_path("fake-openrouter-codex.sh");
let fake_codex_log = temp_path("fake-openrouter-codex-requests.jsonl");
let script = fake_codex_app_server_script(&fake_codex_log);
std::fs::write(&fake_codex, script).expect("write fake codex script");
let mut permissions = std::fs::metadata(&fake_codex)
.expect("fake codex metadata")
.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&fake_codex, permissions).expect("chmod fake codex script");

let mut bridge = BridgeProcess::spawn_harness_blocks_envs(
Harness::Codex,
None,
Some((
"CODEX_BIN",
fake_codex.to_str().expect("utf-8 fake codex path"),
)),
&[("OPENROUTER_MODEL", "openrouter/auto")],
);
let turn = bridge.run_blocks_user_turn("say openrouter blocks", Duration::from_secs(10));
bridge.finish_successfully();

assert_completed_turn(&turn);
assert_codex_v2_turn(&turn);

let requests = std::fs::read_to_string(&fake_codex_log).expect("read fake codex request log");
let requests: Vec<Value> = requests
.lines()
.map(|line| serde_json::from_str(line).expect("fake codex request JSON"))
.collect();
let thread_start = requests
.iter()
.find(|value| value.get("method").and_then(Value::as_str) == Some("thread/start"))
.unwrap_or_else(|| panic!("blocks mode did not send thread/start; requests={requests:?}"));
assert_eq!(
thread_start
.pointer("/params/modelProvider")
.and_then(Value::as_str),
Some("openrouter")
);
let turn_start = requests
.iter()
.find(|value| value.get("method").and_then(Value::as_str) == Some("turn/start"))
.unwrap_or_else(|| panic!("blocks mode did not send turn/start; requests={requests:?}"));
assert_eq!(
turn_start.pointer("/params/model").and_then(Value::as_str),
Some("openrouter/auto")
);

let _ = std::fs::remove_file(fake_codex);
let _ = std::fs::remove_file(fake_codex_log);
}

#[test]
fn fake_codex_blocks_mode_uses_openrouter_provider_for_explicit_model_slug() {
let fake_codex = temp_path("fake-openrouter-flag-codex.sh");
let fake_codex_log = temp_path("fake-openrouter-flag-codex-requests.jsonl");
let script = fake_codex_app_server_script(&fake_codex_log);
std::fs::write(&fake_codex, script).expect("write fake codex script");
let mut permissions = std::fs::metadata(&fake_codex)
.expect("fake codex metadata")
.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&fake_codex, permissions).expect("chmod fake codex script");

let mut bridge = BridgeProcess::spawn_harness_blocks(
Harness::Codex,
None,
Some((
"CODEX_BIN",
fake_codex.to_str().expect("utf-8 fake codex path"),
)),
);
let turn = bridge.run_blocks_user_turn_with_model(
"say explicit openrouter blocks",
Some("anthropic/claude-fable-5"),
Duration::from_secs(10),
);
bridge.finish_successfully();

assert_completed_turn(&turn);
assert_codex_v2_turn(&turn);

let requests = std::fs::read_to_string(&fake_codex_log).expect("read fake codex request log");
let requests: Vec<Value> = requests
.lines()
.map(|line| serde_json::from_str(line).expect("fake codex request JSON"))
.collect();
let thread_start = requests
.iter()
.find(|value| value.get("method").and_then(Value::as_str) == Some("thread/start"))
.unwrap_or_else(|| panic!("blocks mode did not send thread/start; requests={requests:?}"));
assert_eq!(
thread_start
.pointer("/params/modelProvider")
.and_then(Value::as_str),
Some("openrouter")
);
let turn_start = requests
.iter()
.find(|value| value.get("method").and_then(Value::as_str) == Some("turn/start"))
.unwrap_or_else(|| panic!("blocks mode did not send turn/start; requests={requests:?}"));
assert_eq!(
turn_start.pointer("/params/model").and_then(Value::as_str),
Some("anthropic/claude-fable-5")
);

let _ = std::fs::remove_file(fake_codex);
let _ = std::fs::remove_file(fake_codex_log);
}

#[test]
fn fake_amp_app_server_streams_codex_v2_notifications() {
let fake_amp = concat!(
Expand Down Expand Up @@ -612,22 +725,44 @@ impl BridgeProcess {
command_override: Option<String>,
extra_env: Option<(&str, &str)>,
) -> Self {
Self::spawn_harness_with_args(harness, harness.args(), command_override, extra_env)
Self::spawn_harness_with_args(harness, harness.args(), command_override, extra_env, &[])
}

fn spawn_harness_blocks(
harness: Harness,
command_override: Option<String>,
extra_env: Option<(&str, &str)>,
) -> Self {
Self::spawn_harness_with_args(harness, harness.blocks_args(), command_override, extra_env)
Self::spawn_harness_with_args(
harness,
harness.blocks_args(),
command_override,
extra_env,
&[],
)
}

fn spawn_harness_blocks_envs(
harness: Harness,
command_override: Option<String>,
extra_env: Option<(&str, &str)>,
extra_envs: &[(&str, &str)],
) -> Self {
Self::spawn_harness_with_args(
harness,
harness.blocks_args(),
command_override,
extra_env,
extra_envs,
)
}

fn spawn_harness_with_args(
harness: Harness,
args: &'static [&'static str],
command_override: Option<String>,
extra_env: Option<(&str, &str)>,
extra_envs: &[(&str, &str)],
) -> Self {
let bin = env!("CARGO_BIN_EXE_harness-server");
let mut command = Command::new(bin);
Expand All @@ -640,6 +775,9 @@ impl BridgeProcess {
for env_key in [
"CENTAUR_CLAUDE_APP_BRIDGE_COMMAND",
"CENTAUR_AMP_APP_BRIDGE_COMMAND",
"CODEX_MODEL",
"CODEX_MODEL_PROVIDER",
"OPENROUTER_MODEL",
] {
command.env_remove(env_key);
}
Expand All @@ -651,6 +789,9 @@ impl BridgeProcess {
if let Some((key, value)) = extra_env {
command.env(key, value);
}
for (key, value) in extra_envs {
command.env(key, value);
}

Self::spawn_command(command)
}
Expand Down Expand Up @@ -817,7 +958,16 @@ impl BridgeProcess {
}

fn run_blocks_user_turn(&mut self, prompt: &str, timeout: Duration) -> TurnCapture {
self.send(json!({
self.run_blocks_user_turn_with_model(prompt, None, timeout)
}

fn run_blocks_user_turn_with_model(
&mut self,
prompt: &str,
model: Option<&str>,
timeout: Duration,
) -> TurnCapture {
let mut input = json!({
"type": "user",
"thread_key": "slack:C123:123.456",
"trace_metadata": {
Expand All @@ -828,7 +978,11 @@ impl BridgeProcess {
"role": "user",
"content": [{"type": "text", "text": prompt}],
},
}));
});
if let Some(model) = model {
input["model"] = Value::String(model.to_string());
}
self.send(input);

let deadline = Instant::now() + timeout;
let mut capture = TurnCapture::default();
Expand Down
8 changes: 7 additions & 1 deletion 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` |
| Codex with OpenRouter provider | `codex` | none or `--codex` | `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,12 @@ 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`. To run Codex
through OpenRouter, store `OPENROUTER_API_KEY` and set `OPENROUTER_MODEL` to a
model slug such as `openrouter/auto`, or set `CODEX_MODEL_PROVIDER=openrouter`
alongside `CODEX_MODEL`. Per-turn Codex model overrides with provider-style
slugs such as `--model anthropic/claude-fable-5` also select the OpenRouter
provider even when `OPENROUTER_MODEL` is unset.

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
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
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
Loading
Loading