Add decision-auditable OpenRouter transport

This commit is contained in:
Meta Vens 2026-08-22 07:39:46 +02:00
commit ab321b3434
14 changed files with 1061 additions and 103 deletions

View file

@ -71,6 +71,8 @@ struct Args {
model_runtime_bin: PathBuf,
tool_adapter_bin: PathBuf,
model_provider: String,
model: String,
provider_credential_path: Option<PathBuf>,
thread_id: Option<String>,
objective: Option<String>,
cwd: PathBuf,
@ -107,6 +109,8 @@ impl Args {
model_runtime_bin: PathBuf::from(DEFAULT_MODEL_RUNTIME_BIN),
tool_adapter_bin: PathBuf::from(DEFAULT_TOOL_ADAPTER_BIN),
model_provider: "openai-codex".to_string(),
model: "gpt-5.4".to_string(),
provider_credential_path: None,
thread_id: None,
objective: None,
cwd: root.clone(),
@ -153,6 +157,11 @@ impl Args {
"--model-provider" => {
parsed.model_provider = take_string(&mut args, "--model-provider")?;
}
"--model" => parsed.model = take_string(&mut args, "--model")?,
"--provider-credential" => {
parsed.provider_credential_path =
Some(take_path(&mut args, "--provider-credential")?)
}
"--thread-id" => parsed.thread_id = Some(take_string(&mut args, "--thread-id")?),
"--objective" => parsed.objective = Some(take_string(&mut args, "--objective")?),
"--proposal-modeling-request-id" => {
@ -393,6 +402,8 @@ fn run_coordinator(args: &Args) -> Result<Value> {
&model_runtime_bin,
&tool_adapter_bin,
&args.model_provider,
&args.model,
args.provider_credential_path.as_deref(),
&runtime_store,
&codex_home,
&mcp_config,
@ -434,6 +445,8 @@ fn run_coordinator(args: &Args) -> Result<Value> {
&model_runtime_bin,
&tool_adapter_bin,
&args.model_provider,
&args.model,
args.provider_credential_path.as_deref(),
&runtime_store,
&codex_home,
&mcp_config,
@ -487,6 +500,8 @@ fn run_coordinator(args: &Args) -> Result<Value> {
&model_runtime_bin,
&tool_adapter_bin,
&args.model_provider,
&args.model,
args.provider_credential_path.as_deref(),
&runtime_store,
&codex_home,
&mcp_config,
@ -639,6 +654,8 @@ fn run_coordinator(args: &Args) -> Result<Value> {
&model_runtime_bin,
&tool_adapter_bin,
&args.model_provider,
&args.model,
args.provider_credential_path.as_deref(),
&runtime_store,
&codex_home,
&mcp_config,
@ -688,6 +705,8 @@ fn run_coordinator(args: &Args) -> Result<Value> {
&model_runtime_bin,
&tool_adapter_bin,
&args.model_provider,
&args.model,
args.provider_credential_path.as_deref(),
&runtime_store,
&codex_home,
&mcp_config,
@ -1009,6 +1028,8 @@ fn run_coordinator(args: &Args) -> Result<Value> {
&model_runtime_bin,
&tool_adapter_bin,
&args.model_provider,
&args.model,
args.provider_credential_path.as_deref(),
&runtime_store,
&codex_home,
&mcp_config,
@ -1051,6 +1072,8 @@ fn run_coordinator(args: &Args) -> Result<Value> {
&model_runtime_bin,
&tool_adapter_bin,
&args.model_provider,
&args.model,
args.provider_credential_path.as_deref(),
&runtime_store,
&codex_home,
&mcp_config,
@ -1171,6 +1194,8 @@ fn run_coordinator(args: &Args) -> Result<Value> {
&model_runtime_bin,
&tool_adapter_bin,
&args.model_provider,
&args.model,
args.provider_credential_path.as_deref(),
&runtime_store,
&codex_home,
&mcp_config,
@ -1240,6 +1265,8 @@ fn run_coordinator(args: &Args) -> Result<Value> {
&model_runtime_bin,
&tool_adapter_bin,
&args.model_provider,
&args.model,
args.provider_credential_path.as_deref(),
&runtime_store,
&codex_home,
&mcp_config,
@ -1816,6 +1843,8 @@ fn launch_worker_runtime_detached(
model_runtime_bin: &Path,
tool_adapter_bin: &Path,
model_provider: &str,
model: &str,
provider_credential_path: Option<&Path>,
runtime_store: &Path,
codex_home: &Path,
mcp_config: &Path,
@ -1843,6 +1872,8 @@ fn launch_worker_runtime_detached(
.arg("run-worker")
.arg("--provider")
.arg(model_provider)
.arg("--model")
.arg(model)
.arg("--store")
.arg(runtime_store)
.arg("--codex-home")
@ -1855,6 +1886,9 @@ fn launch_worker_runtime_detached(
.arg(&activation_token_sha256)
.arg("--max-runtime-seconds")
.arg(max_runtime_seconds.to_string());
if let Some(path) = provider_credential_path {
command.arg("--provider-credential").arg(path);
}
if auto_tools {
command
.arg("--auto-tools")
@ -2223,6 +2257,8 @@ mod tests {
model_runtime_bin: current_exe.clone(),
tool_adapter_bin: current_exe,
model_provider: "test".to_string(),
model: "test-model".to_string(),
provider_credential_path: None,
thread_id: Some("error-terminalization".to_string()),
objective: Some("Exercise the coordinator error boundary.".to_string()),
cwd: cwd.clone(),

View file

@ -563,6 +563,11 @@ impl Args {
.get("--model-provider")
.cloned()
.ok_or_else(|| anyhow!("missing --model-provider"))?,
model: value
.get("--model")
.cloned()
.ok_or_else(|| anyhow!("missing --model"))?,
provider_credential_path: value.get("--provider-credential").map(PathBuf::from),
max_steps: u64v("--max-steps", 4)?,
turn_timeout_seconds: u64v("--turn-timeout-seconds", 600)?,
cooldown_seconds: u64v("--cooldown-seconds", 60)?,
@ -831,6 +836,8 @@ mod brake_tests {
codex_home: shared.join("codex-home"),
mcp_config: shared.join("mcp.toml"),
model_provider: "test".into(),
model: "test-model".into(),
provider_credential_path: None,
max_steps: 1,
turn_timeout_seconds: 1,
cooldown_seconds: 1,

View file

@ -18,11 +18,11 @@ use std::collections::BTreeSet;
use std::path::Path;
pub const REASONING_BASIS_SCHEMA_VERSION: &str = "epiphany.reasoning_basis.v1";
pub const DECISION_CONTEXT_SCHEMA_VERSION: &str = "epiphany.decision_context.v1";
pub const DECISION_CONTEXT_SCHEMA_VERSION: &str = "epiphany.decision_context.v2";
pub const MODEL_PASS_FAILURE_SCHEMA_VERSION: &str = "epiphany.model_pass_failure.v1";
pub const MIND_COMMIT_RECEIPT_SCHEMA_VERSION: &str = "epiphany.mind_commit_receipt.v1";
pub const REASONING_BASIS_TYPE: &str = "epiphany.reasoning_basis.v1";
pub const DECISION_CONTEXT_TYPE: &str = "epiphany.decision_context.v1";
pub const DECISION_CONTEXT_TYPE: &str = "epiphany.decision_context.v2";
pub const MODEL_PASS_FAILURE_TYPE: &str = "epiphany.model_pass_failure.v1";
pub const MIND_COMMIT_RECEIPT_TYPE: &str = "epiphany.mind_commit_receipt.v1";
pub const DECISION_AUDIT_PROJECTION_SCHEMA_VERSION: &str = "epiphany.decision_audit_projection.v1";
@ -349,7 +349,7 @@ pub struct EpiphanyDecisionToolObservation {
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
#[cultcache(
type = "epiphany.decision_context.v1",
type = "epiphany.decision_context.v2",
schema = "EpiphanyDecisionContext"
)]
pub struct EpiphanyDecisionContext {
@ -1970,6 +1970,8 @@ mod tests {
previous_response_id: None,
tools: Vec::new(),
output_schema_json: None,
provider_id: native.provider.clone(),
wire_dialect: epiphany_openai_adapter::EpiphanyOpenAiWireDialect::Responses,
};
(native, provider)
}
@ -2088,6 +2090,42 @@ mod tests {
Ok(())
}
#[test]
fn decision_context_seals_openrouter_identity_and_terminal_tool_dialect() -> Result<()> {
let reasoning_basis = basis()?;
let mut native = EpiphanyModelRequest::new(
"request-openrouter",
"conversation-openrouter",
"openrouter",
"stealth/ox-alpha",
"decide",
);
native.reasoning_basis_id = Some(reasoning_basis.basis_id.clone());
native.output_contract_id = Some("epiphany.test.output.v0".into());
native.output_schema_json = Some(
serde_json::json!({
"type": "object",
"additionalProperties": false,
"properties": {"status": {"type": "string"}},
"required": ["status"]
})
.to_string(),
);
let context = EpiphanyDecisionContext::new(&reasoning_basis, native.clone(), Vec::new())?;
let provider = context.provider_request()?;
assert_eq!(provider.provider_id, "openrouter");
assert_eq!(
provider.wire_dialect,
epiphany_openai_adapter::EpiphanyOpenAiWireDialect::ChatCompletionsTerminalTool
);
assert_eq!(
provider,
epiphany_openai_adapter::request_from_native(&native)
);
Ok(())
}
#[test]
fn decision_context_reuses_runtime_binding_owner_and_survives_transcript_deletion() -> Result<()>
{

View file

@ -328,7 +328,7 @@ pub fn resident_self_local_provider_status(
) -> &'static str {
if validate_resident_self_store_separation(resident_store, policy).is_err()
|| !directory_ready(&policy.workspace)
|| !codex_credentials_ready(&policy.codex_home)
|| !model_provider_credential_ready(policy)
{
return "warming";
}
@ -482,9 +482,12 @@ pub fn derive_resident_cognition_readiness(
if !workspace_ready {
reasons.push("workspace is absent, inaccessible, or read-only".into());
}
let credential_ready = codex_credentials_ready(&request.policy.codex_home);
let credential_ready = model_provider_credential_ready(&request.policy);
if !credential_ready {
reasons.push("Codex credential material is unavailable".into());
reasons.push(format!(
"model-provider credential material is unavailable for {}",
request.policy.model_provider
));
}
let ready = release_authenticated
&& physical_stores_separate
@ -794,6 +797,20 @@ fn codex_credentials_ready(codex_home: &Path) -> bool {
directory_ready(codex_home) && credential_file_ready(&codex_home.join("auth.json"))
}
fn model_provider_credential_ready(policy: &ResidentSelfPolicy) -> bool {
match policy.model_provider.as_str() {
"openai-codex" | "openai" => codex_credentials_ready(&policy.codex_home),
"openrouter" => policy
.provider_credential_path
.as_deref()
.is_some_and(|path| {
fs::metadata(path).is_ok_and(|metadata| metadata.is_file())
&& fs::File::open(path).is_ok()
}),
_ => false,
}
}
#[cfg(any(unix, test))]
fn unix_credential_mode_ready(mode: u32) -> bool {
mode & 0o600 == 0o600 && mode & 0o077 == 0
@ -1129,6 +1146,8 @@ mod tests {
codex_home: shared.join("codex-home"),
mcp_config: shared.join("mcp.toml"),
model_provider: "test".into(),
model: "test-model".into(),
provider_credential_path: None,
max_steps: 1,
turn_timeout_seconds: 1,
cooldown_seconds: 1,

View file

@ -295,6 +295,8 @@ pub struct ResidentSelfPolicy {
pub codex_home: PathBuf,
pub mcp_config: PathBuf,
pub model_provider: String,
pub model: String,
pub provider_credential_path: Option<PathBuf>,
pub max_steps: u64,
pub turn_timeout_seconds: u64,
pub cooldown_seconds: u64,
@ -330,7 +332,16 @@ impl ResidentSelfPolicy {
));
}
}
if let Some(path) = self.provider_credential_path.as_ref()
&& !path.is_absolute()
{
return Err(anyhow!(
"resident Self provider credential path must be absolute: {}",
path.display()
));
}
if self.model_provider.trim().is_empty()
|| self.model.trim().is_empty()
|| self.max_steps == 0
|| self.turn_timeout_seconds == 0
{
@ -645,6 +656,8 @@ pub fn coordinator_argv(
policy.tool_adapter_bin.display().to_string(),
"--model-provider".into(),
policy.model_provider.clone(),
"--model".into(),
policy.model.clone(),
"--runtime-id".into(),
runtime_id.into(),
"--thread-id".into(),
@ -670,6 +683,9 @@ pub fn coordinator_argv(
"--max-runtime-seconds".into(),
policy.turn_timeout_seconds.to_string(),
];
if let Some(path) = policy.provider_credential_path.as_ref() {
argv.extend(["--provider-credential".into(), path.display().to_string()]);
}
let ResidentSelfWake::Explicit { objective } = wake;
argv.extend(["--objective".into(), objective.clone()]);
argv

View file

@ -157,7 +157,7 @@ pub const COORDINATOR_RUN_RECEIPT_RETENTION_HEAD_SCHEMA_VERSION: &str =
"epiphany.coordinator_run_receipt_retention_head.v0";
pub const COORDINATOR_RUN_RECEIPT_RETENTION_HEAD_KEY: &str = "coordinator-run-receipt-retention";
pub const OPENAI_ADAPTER_STATUS_TYPE: &str = "epiphany.openai_adapter_status.v0";
pub const OPENAI_MODEL_REQUEST_TYPE: &str = "epiphany.openai_model_request.v0";
pub const OPENAI_MODEL_REQUEST_TYPE: &str = "epiphany.openai_model_request.v1";
pub const OPENAI_MODEL_STREAM_EVENT_TYPE: &str = "epiphany.openai_model_stream_event.v0";
pub const OPENAI_MODEL_RECEIPT_TYPE: &str = "epiphany.openai_model_receipt.v0";
pub const MODEL_ADAPTER_STATUS_TYPE: &str = "epiphany.model_adapter_status.v0";
@ -170,7 +170,7 @@ pub const TOOL_INVOCATION_RECEIPT_TYPE: &str = "epiphany.tool_invocation_receipt
pub const RUNTIME_IDENTITY_KEY: &str = "self";
pub const RUNTIME_SWARM_BINDING_KEY: &str = "runtime-swarm-binding";
pub const RUNTIME_SWARM_BINDING_SCHEMA_VERSION: &str = "epiphany.runtime.swarm_binding.v0";
pub const RUNTIME_SPINE_SCHEMA_VERSION: &str = "epiphany.runtime_spine.v2";
pub const RUNTIME_SPINE_SCHEMA_VERSION: &str = "epiphany.runtime_spine.v3";
pub const EPIPHANY_RUNTIME_ROOT_SESSION_ID: &str = "epiphany-main";
pub const RUNTIME_MODEL_EXECUTION_BINDING_SCHEMA_VERSION: &str =
"epiphany.runtime.model_execution_binding.v0";
@ -189,7 +189,7 @@ pub const RUNTIME_REORIENT_WORKER_RESULT_SCHEMA_VERSION: &str =
"epiphany.runtime.reorient_worker_result.v0";
pub const COORDINATOR_RUN_RECEIPT_SCHEMA_VERSION: &str = "epiphany.coordinator_run_receipt.v0";
pub const OPENAI_ADAPTER_STATUS_SCHEMA_VERSION: &str = "epiphany.openai_adapter_status.v0";
pub const OPENAI_MODEL_REQUEST_SCHEMA_VERSION: &str = "epiphany.openai_model_request.v0";
pub const OPENAI_MODEL_REQUEST_SCHEMA_VERSION: &str = "epiphany.openai_model_request.v1";
pub const OPENAI_MODEL_STREAM_EVENT_SCHEMA_VERSION: &str = "epiphany.openai_model_stream_event.v0";
pub const OPENAI_MODEL_RECEIPT_SCHEMA_VERSION: &str = "epiphany.openai_model_receipt.v0";
pub const MODEL_ADAPTER_STATUS_SCHEMA_VERSION: &str = "epiphany.model_adapter_status.v0";
@ -2751,8 +2751,7 @@ where
.ok_or_else(|| anyhow!("archived model execution lost provider request"))?;
if native_request.request_id != *request_id
|| provider_request.request_id != *request_id
|| native_request.conversation_id != provider_request.conversation_id
|| native_request.model != provider_request.model
|| provider_request != epiphany_openai_adapter::request_from_native(&native_request)
{
return Err(anyhow!(
"archived model execution request family is inconsistent"
@ -13986,6 +13985,40 @@ pub(crate) mod tests {
runtime_spine_backing_store(&archive_v0_store)?.pull_all()?,
archive_v0_before
);
let responses_only_store = temp.path().join("responses-only-runtime.cc");
let mut responses_only = CultCache::new();
responses_only.register_entry_type::<crate::EpiphanyMindIdentity>()?;
responses_only.register_entry_type::<EpiphanyRuntimeIdentity>()?;
responses_only
.add_generic_backing_store(runtime_spine_backing_store(&responses_only_store)?);
responses_only.put(
crate::MIND_SCHEMA_EPOCH,
&crate::EpiphanyMindIdentity {
schema_epoch: crate::MIND_SCHEMA_EPOCH.into(),
runtime_id: "responses-only-runtime".into(),
},
)?;
responses_only.put(
RUNTIME_IDENTITY_KEY,
&EpiphanyRuntimeIdentity {
schema_version: "epiphany.runtime_spine.v2".into(),
runtime_id: "responses-only-runtime".into(),
display_name: "Responses-only runtime".into(),
runtime_kind: "epiphany.native".into(),
created_at: "2026-08-22T00:00:00Z".into(),
updated_at: "2026-08-22T00:00:00Z".into(),
supported_document_types: Vec::new(),
metadata: BTreeMap::new(),
},
)?;
let responses_only_before =
runtime_spine_backing_store(&responses_only_store)?.pull_all()?;
assert!(runtime_spine_cache(&responses_only_store).is_err());
assert_eq!(
runtime_spine_backing_store(&responses_only_store)?.pull_all()?,
responses_only_before
);
Ok(())
}
}

View file

@ -3,7 +3,7 @@ use epiphany_model_adapter::{EpiphanyModelInputItem, EpiphanyModelRequest};
use serde::Deserialize;
use serde::Serialize;
pub const OPENAI_ADAPTER_REQUEST_SCHEMA_ID: &str = "epiphany.openai_model_request.v0";
pub const OPENAI_ADAPTER_REQUEST_SCHEMA_ID: &str = "epiphany.openai_model_request.v1";
pub const OPENAI_ADAPTER_EVENT_SCHEMA_ID: &str = "epiphany.openai_model_stream_event.v0";
pub const OPENAI_ADAPTER_RECEIPT_SCHEMA_ID: &str = "epiphany.openai_model_receipt.v0";
pub const OPENAI_ADAPTER_STATUS_SCHEMA_ID: &str = "epiphany.openai_adapter_status.v0";
@ -16,6 +16,12 @@ pub enum EpiphanyOpenAiAuthMode {
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EpiphanyOpenAiWireDialect {
Responses,
ChatCompletionsTerminalTool,
}
#[derive(Debug, Clone, PartialEq, Eq, DatabaseEntry)]
#[cultcache(
type = "epiphany.openai_adapter_status.v0",
@ -42,7 +48,7 @@ pub struct EpiphanyOpenAiAdapterStatus {
#[derive(Debug, Clone, PartialEq, DatabaseEntry)]
#[cultcache(
type = "epiphany.openai_model_request.v0",
type = "epiphany.openai_model_request.v1",
schema = "EpiphanyOpenAiModelRequest"
)]
pub struct EpiphanyOpenAiModelRequest {
@ -72,6 +78,10 @@ pub struct EpiphanyOpenAiModelRequest {
pub tools: Vec<EpiphanyOpenAiToolDefinition>,
#[cultcache(key = 12, default)]
pub output_schema_json: Option<String>,
#[cultcache(key = 13)]
pub provider_id: String,
#[cultcache(key = 14)]
pub wire_dialect: EpiphanyOpenAiWireDialect,
}
impl EpiphanyOpenAiModelRequest {
@ -95,6 +105,8 @@ impl EpiphanyOpenAiModelRequest {
previous_response_id: None,
tools: Vec::new(),
output_schema_json: None,
provider_id: "openai-codex".to_string(),
wire_dialect: EpiphanyOpenAiWireDialect::Responses,
}
}
}
@ -125,6 +137,12 @@ pub fn request_from_native(request: &EpiphanyModelRequest) -> EpiphanyOpenAiMode
})
.collect(),
output_schema_json: request.output_schema_json.clone(),
provider_id: request.provider.clone(),
wire_dialect: if request.provider == "openrouter" {
EpiphanyOpenAiWireDialect::ChatCompletionsTerminalTool
} else {
EpiphanyOpenAiWireDialect::Responses
},
}
}
@ -255,3 +273,37 @@ impl EpiphanyOpenAiModelReceipt {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn native_provider_identity_selects_one_exact_wire_dialect() {
let openai = EpiphanyModelRequest::new(
"openai-request",
"conversation",
"openai-codex",
"gpt-test",
"decide",
);
let openrouter = EpiphanyModelRequest::new(
"openrouter-request",
"conversation",
"openrouter",
"stealth/ox-alpha",
"decide",
);
let openai = request_from_native(&openai);
let openrouter = request_from_native(&openrouter);
assert_eq!(openai.provider_id, "openai-codex");
assert_eq!(openai.wire_dialect, EpiphanyOpenAiWireDialect::Responses);
assert_eq!(openrouter.provider_id, "openrouter");
assert_eq!(
openrouter.wire_dialect,
EpiphanyOpenAiWireDialect::ChatCompletionsTerminalTool
);
assert_ne!(openai.schema_id, "epiphany.openai_model_request.v0");
}
}

View file

@ -16,6 +16,7 @@ use epiphany_openai_adapter::EpiphanyOpenAiModelReceipt;
use epiphany_openai_adapter::EpiphanyOpenAiModelRequest;
use epiphany_openai_adapter::EpiphanyOpenAiStreamEvent;
use epiphany_openai_adapter::EpiphanyOpenAiStreamPayload;
use epiphany_openai_adapter::EpiphanyOpenAiWireDialect;
use epiphany_openai_adapter::OPENAI_ADAPTER_STATUS_SCHEMA_ID;
use epiphany_openai_auth_spine::AuthCredentialsStoreMode;
use epiphany_openai_auth_spine::AuthManager;
@ -30,12 +31,15 @@ use sha2::Sha256;
const CHATGPT_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api/codex";
const OPENAI_API_BASE_URL: &str = "https://api.openai.com/v1";
const OPENROUTER_API_BASE_URL: &str = "https://openrouter.ai/api/v1";
// Keep provider silence shorter than Epiphany worker watchdogs so the runtime can
// write a typed stream failure instead of being killed with no model evidence.
const RESPONSES_STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(45);
const RESPONSES_CALL_ID_MAX_BYTES: usize = 64;
pub const CODEX_SPINE_ADAPTER_ID: &str = "codex-openai-subscription-spine";
pub const OPENROUTER_SPINE_ADAPTER_ID: &str = "openrouter-chat-completions-spine";
pub const OPENROUTER_TERMINAL_TOOL_NAME: &str = "epiphany_submit_typed_result";
pub fn default_codex_home() -> Result<std::path::PathBuf> {
if let Ok(path) = std::env::var("CODEX_HOME") {
@ -100,6 +104,22 @@ pub fn status_from_codex_auth(
}
}
pub fn status_from_static_api_key(
adapter_id: &str,
default_model: Option<String>,
) -> EpiphanyOpenAiAdapterStatus {
EpiphanyOpenAiAdapterStatus {
schema_id: OPENAI_ADAPTER_STATUS_SCHEMA_ID.to_string(),
adapter_id: adapter_id.to_string(),
auth_mode: EpiphanyOpenAiAuthMode::ApiKey,
account_id: None,
plan_type: None,
default_model,
supports_websockets: false,
codex_transport_attached: false,
}
}
pub struct EpiphanyCodexOpenAiTransport {
auth_manager: Arc<AuthManager>,
base_url: Option<String>,
@ -208,9 +228,351 @@ impl EpiphanyCodexOpenAiTransport {
}
}
/// OpenRouter owns a distinct credential and wire dialect. It is intentionally
/// not routed through Codex auth or the Responses transport.
pub struct EpiphanyOpenRouterTransport {
api_key: String,
base_url: String,
}
impl EpiphanyOpenRouterTransport {
pub fn new(api_key: impl Into<String>) -> Result<Self> {
let api_key = api_key.into();
if api_key.trim().is_empty() || api_key.trim() != api_key {
return Err(anyhow::anyhow!(
"OpenRouter API key must be a non-empty trimmed credential"
));
}
Ok(Self {
api_key,
base_url: OPENROUTER_API_BASE_URL.to_string(),
})
}
pub async fn collect_model_events(
&self,
request: EpiphanyOpenAiModelRequest,
) -> Result<Vec<EpiphanyOpenAiStreamEvent>> {
self.collect_model_events_with_frame_observer(request, |_| {})
.await
}
pub async fn collect_model_events_with_frame_observer(
&self,
request: EpiphanyOpenAiModelRequest,
mut observe_frame: impl FnMut(EpiphanyResponsesFrameObservation),
) -> Result<Vec<EpiphanyOpenAiStreamEvent>> {
let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
let body = chat_completions_body_from_epiphany(&request)?;
let mut outbound = Request::new(http::Method::POST, url).with_json(&body);
let mut authorization = format!("Bearer {}", self.api_key).parse::<http::HeaderValue>()?;
authorization.set_sensitive(true);
outbound
.headers
.insert(http::header::AUTHORIZATION, authorization);
outbound.timeout = Some(Duration::from_secs(90));
let transport = ReqwestTransport::new(build_reqwest_client());
let response = transport
.execute(outbound)
.await
.map_err(transport_error_to_anyhow)?;
observe_frame(EpiphanyResponsesFrameObservation {
frame_sequence: 1,
kind: "chat.completion".to_string(),
recognized: true,
delta_preview: None,
});
let response: OpenRouterChatCompletionResponse = serde_json::from_slice(&response.body)
.context("OpenRouter returned an invalid Chat Completions response")?;
openrouter_events_from_chat_completion(&request, response)
}
}
pub fn chat_completions_body_from_epiphany(
request: &EpiphanyOpenAiModelRequest,
) -> Result<serde_json::Value> {
if request.provider_id != "openrouter"
|| request.wire_dialect != EpiphanyOpenAiWireDialect::ChatCompletionsTerminalTool
{
return Err(anyhow::anyhow!(
"OpenRouter transport requires its exact provider identity and Chat Completions dialect"
));
}
let has_tool_result = request
.input
.iter()
.any(|item| matches!(item, EpiphanyOpenAiInputItem::ToolResult { .. }));
let output_schema = request
.output_schema_json
.as_deref()
.map(strict_provider_schema)
.transpose()?;
let include_terminal_tool =
output_schema.is_some() && (request.tools.is_empty() || has_tool_result);
if request
.tools
.iter()
.any(|tool| tool.name == OPENROUTER_TERMINAL_TOOL_NAME)
{
return Err(anyhow::anyhow!(
"native tool name collides with the OpenRouter terminal decision tool"
));
}
let mut messages = vec![serde_json::json!({
"role": "system",
"content": request.instructions,
})];
for item in &request.input {
messages.push(match item {
EpiphanyOpenAiInputItem::UserText { text } => serde_json::json!({
"role": "user",
"content": text,
}),
EpiphanyOpenAiInputItem::AssistantText { text } => serde_json::json!({
"role": "assistant",
"content": text,
}),
EpiphanyOpenAiInputItem::ToolCall {
call_id,
name,
arguments,
} => serde_json::json!({
"role": "assistant",
"tool_calls": [{
"id": call_id,
"type": "function",
"function": {"name": name, "arguments": arguments},
}],
}),
EpiphanyOpenAiInputItem::ToolResult { call_id, output } => serde_json::json!({
"role": "tool",
"tool_call_id": call_id,
"content": output,
}),
});
}
let mut tools = request
.tools
.iter()
.map(|tool| {
let parameters = strict_provider_schema(&tool.parameters_json)?;
Ok(serde_json::json!({
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"strict": true,
"parameters": parameters,
},
}))
})
.collect::<Result<Vec<_>>>()?;
if let Some(schema) = output_schema {
if include_terminal_tool {
tools.push(serde_json::json!({
"type": "function",
"function": {
"name": OPENROUTER_TERMINAL_TOOL_NAME,
"description": "Submit the final typed Epiphany decision. Call this only when the pass is complete.",
"strict": true,
"parameters": schema,
},
}));
}
}
let mut body = serde_json::json!({
"model": request.model,
"messages": messages,
"stream": false,
"parallel_tool_calls": false,
});
if !tools.is_empty() {
body["tools"] = serde_json::Value::Array(tools);
body["tool_choice"] = serde_json::Value::String(
if include_terminal_tool || (!request.tools.is_empty() && !has_tool_result) {
"required"
} else {
"auto"
}
.to_string(),
);
}
if let Some(effort) = request.reasoning_effort.as_deref() {
body["reasoning"] = serde_json::json!({"effort": effort});
}
Ok(body)
}
fn strict_provider_schema(schema_json: &str) -> Result<serde_json::Value> {
let mut schema: serde_json::Value = serde_json::from_str(schema_json)
.context("provider tool parameters are not valid JSON Schema")?;
project_strict_responses_schema(&mut schema)?;
Ok(schema)
}
#[derive(Debug, Deserialize)]
struct OpenRouterChatCompletionResponse {
id: Option<String>,
model: Option<String>,
choices: Vec<OpenRouterChatChoice>,
usage: Option<OpenRouterChatUsage>,
}
#[derive(Debug, Deserialize)]
struct OpenRouterChatChoice {
message: OpenRouterChatMessage,
}
#[derive(Debug, Deserialize)]
struct OpenRouterChatMessage {
content: Option<String>,
#[serde(default)]
reasoning: Option<String>,
#[serde(default)]
tool_calls: Vec<OpenRouterChatToolCall>,
}
#[derive(Debug, Deserialize)]
struct OpenRouterChatToolCall {
id: String,
function: OpenRouterChatFunctionCall,
}
#[derive(Debug, Deserialize)]
struct OpenRouterChatFunctionCall {
name: String,
arguments: String,
}
#[derive(Debug, Deserialize)]
struct OpenRouterChatUsage {
prompt_tokens: Option<u64>,
completion_tokens: Option<u64>,
completion_tokens_details: Option<OpenRouterCompletionTokenDetails>,
}
#[derive(Debug, Deserialize)]
struct OpenRouterCompletionTokenDetails {
reasoning_tokens: Option<u64>,
}
fn openrouter_events_from_chat_completion(
request: &EpiphanyOpenAiModelRequest,
response: OpenRouterChatCompletionResponse,
) -> Result<Vec<EpiphanyOpenAiStreamEvent>> {
let choice = response
.choices
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("OpenRouter response contained no completion choice"))?;
let mut events = Vec::new();
if let Some(reasoning) = choice.message.reasoning.filter(|value| !value.is_empty()) {
push_openrouter_event(
&mut events,
&request.request_id,
EpiphanyOpenAiStreamPayload::ReasoningDelta { text: reasoning },
);
}
let terminal_calls = choice
.message
.tool_calls
.iter()
.filter(|call| call.function.name == OPENROUTER_TERMINAL_TOOL_NAME)
.collect::<Vec<_>>();
if !terminal_calls.is_empty() {
if terminal_calls.len() != 1 || choice.message.tool_calls.len() != 1 {
return Err(anyhow::anyhow!(
"OpenRouter mixed the terminal decision tool with another tool call"
));
}
let arguments = &terminal_calls[0].function.arguments;
if !matches!(
serde_json::from_str::<serde_json::Value>(arguments),
Ok(serde_json::Value::Object(_))
) {
return Err(anyhow::anyhow!(
"OpenRouter terminal decision tool returned non-object arguments"
));
}
push_openrouter_event(
&mut events,
&request.request_id,
EpiphanyOpenAiStreamPayload::TextDelta {
text: arguments.clone(),
},
);
} else {
if let Some(content) = choice.message.content.filter(|value| !value.is_empty()) {
push_openrouter_event(
&mut events,
&request.request_id,
EpiphanyOpenAiStreamPayload::TextDelta { text: content },
);
}
for call in choice.message.tool_calls {
push_openrouter_event(
&mut events,
&request.request_id,
EpiphanyOpenAiStreamPayload::ToolCall {
call_id: call.id,
name: call.function.name,
arguments: call.function.arguments,
},
);
}
}
if events.is_empty() {
return Err(anyhow::anyhow!(
"OpenRouter response contained neither text nor tool calls"
));
}
let usage = response.usage;
let mut receipt = EpiphanyOpenAiModelReceipt::new(
&request.request_id,
response.model.unwrap_or_else(|| request.model.clone()),
);
receipt.response_id = response.id;
receipt.input_tokens = usage.as_ref().and_then(|item| item.prompt_tokens);
receipt.output_tokens = usage.as_ref().and_then(|item| item.completion_tokens);
receipt.reasoning_output_tokens = usage
.as_ref()
.and_then(|item| item.completion_tokens_details.as_ref())
.and_then(|item| item.reasoning_tokens);
receipt.transport = Some("openrouter-chat-completions".to_string());
push_openrouter_event(
&mut events,
&request.request_id,
EpiphanyOpenAiStreamPayload::Completed { receipt },
);
Ok(events)
}
fn push_openrouter_event(
events: &mut Vec<EpiphanyOpenAiStreamEvent>,
request_id: &str,
payload: EpiphanyOpenAiStreamPayload,
) {
events.push(EpiphanyOpenAiStreamEvent {
schema_id: epiphany_openai_adapter::OPENAI_ADAPTER_EVENT_SCHEMA_ID.to_string(),
request_id: request_id.to_string(),
sequence: events.len() as u64,
payload,
});
}
pub fn responses_body_from_epiphany(
request: EpiphanyOpenAiModelRequest,
) -> Result<serde_json::Value> {
if !matches!(request.provider_id.as_str(), "openai-codex" | "openai")
|| request.wire_dialect != EpiphanyOpenAiWireDialect::Responses
{
return Err(anyhow::anyhow!(
"Responses transport requires an OpenAI provider identity and Responses dialect"
));
}
let requires_initial_tool = !request.tools.is_empty()
&& !request
.input
@ -1138,6 +1500,22 @@ fn auth_mode_from_codex_auth(auth: Option<&CodexAuth>) -> EpiphanyOpenAiAuthMode
mod tests {
use super::*;
fn openrouter_request(
request_id: &str,
conversation_id: &str,
instructions: &str,
) -> EpiphanyOpenAiModelRequest {
let mut request = EpiphanyOpenAiModelRequest::new(
request_id,
conversation_id,
"stealth/ox-alpha",
instructions,
);
request.provider_id = "openrouter".to_string();
request.wire_dialect = EpiphanyOpenAiWireDialect::ChatCompletionsTerminalTool;
request
}
#[test]
fn api_key_auth_maps_to_typed_adapter_status() {
let auth = CodexAuth::from_api_key("test-key");
@ -1159,6 +1537,161 @@ mod tests {
assert!(!status.supports_websockets);
}
#[test]
fn static_openrouter_status_does_not_claim_codex_transport() {
let status = status_from_static_api_key(
OPENROUTER_SPINE_ADAPTER_ID,
Some("stealth/ox-alpha".to_string()),
);
assert_eq!(status.adapter_id, OPENROUTER_SPINE_ADAPTER_ID);
assert_eq!(status.auth_mode, EpiphanyOpenAiAuthMode::ApiKey);
assert_eq!(status.default_model.as_deref(), Some("stealth/ox-alpha"));
assert!(!status.supports_websockets);
assert!(!status.codex_transport_attached);
}
#[test]
fn openrouter_terminal_tool_owns_typed_output_instead_of_response_format() {
let mut request = openrouter_request(
"req-terminal",
"conversation-terminal",
"Return the typed result.",
);
request.input.push(EpiphanyOpenAiInputItem::UserText {
text: "Decide.".to_string(),
});
request.output_schema_json = Some(
serde_json::json!({
"type": "object",
"additionalProperties": false,
"properties": {"status": {"type": "string", "enum": ["ok"]}},
"required": ["status"]
})
.to_string(),
);
let body = chat_completions_body_from_epiphany(&request).expect("request should map");
assert!(body.get("response_format").is_none());
assert_eq!(body["tool_choice"], "required");
assert_eq!(body["tools"].as_array().expect("tools").len(), 1);
assert_eq!(
body["tools"][0]["function"]["name"],
OPENROUTER_TERMINAL_TOOL_NAME
);
assert_eq!(
body["tools"][0]["function"]["parameters"]["properties"]["status"]["enum"][0],
"ok"
);
}
#[test]
fn openrouter_requires_source_sight_before_exposing_terminal_decision() {
let mut request =
openrouter_request("req-tools", "conversation-tools", "Inspect, then decide.");
request.input.push(EpiphanyOpenAiInputItem::UserText {
text: "Inspect the source.".to_string(),
});
request
.tools
.push(epiphany_openai_adapter::EpiphanyOpenAiToolDefinition {
name: "mcp__epiphany_source__read_file".to_string(),
description: "Read a file.".to_string(),
parameters_json: serde_json::json!({
"type": "object",
"additionalProperties": false,
"properties": {"path": {"type": "string"}},
"required": ["path"]
})
.to_string(),
});
request.output_schema_json = Some(
serde_json::json!({
"type": "object",
"additionalProperties": false,
"properties": {"status": {"type": "string"}},
"required": ["status"]
})
.to_string(),
);
let initial = chat_completions_body_from_epiphany(&request).expect("initial request");
assert_eq!(initial["tool_choice"], "required");
assert_eq!(initial["tools"].as_array().expect("tools").len(), 1);
assert_eq!(
initial["tools"][0]["function"]["name"],
"mcp__epiphany_source__read_file"
);
request.input.push(EpiphanyOpenAiInputItem::ToolCall {
call_id: "call-1".to_string(),
name: "mcp__epiphany_source__read_file".to_string(),
arguments: r#"{"path":"README.md"}"#.to_string(),
});
request.input.push(EpiphanyOpenAiInputItem::ToolResult {
call_id: "call-1".to_string(),
output: r#"{"ok":true}"#.to_string(),
});
let followup = chat_completions_body_from_epiphany(&request).expect("followup request");
assert_eq!(followup["tool_choice"], "required");
assert_eq!(followup["tools"].as_array().expect("tools").len(), 2);
assert_eq!(
followup["tools"][1]["function"]["name"],
OPENROUTER_TERMINAL_TOOL_NAME
);
assert_eq!(followup["messages"][2]["tool_calls"][0]["id"], "call-1");
assert_eq!(followup["messages"][3]["tool_call_id"], "call-1");
}
#[test]
fn openrouter_terminal_call_becomes_exact_assistant_json() {
let request = openrouter_request(
"req-terminal-events",
"conversation-terminal-events",
"Return the typed result.",
);
let response = OpenRouterChatCompletionResponse {
id: Some("generation-1".to_string()),
model: Some("stealth/ox-alpha".to_string()),
choices: vec![OpenRouterChatChoice {
message: OpenRouterChatMessage {
content: None,
reasoning: None,
tool_calls: vec![OpenRouterChatToolCall {
id: "call-terminal".to_string(),
function: OpenRouterChatFunctionCall {
name: OPENROUTER_TERMINAL_TOOL_NAME.to_string(),
arguments: r#"{"status":"ok"}"#.to_string(),
},
}],
},
}],
usage: Some(OpenRouterChatUsage {
prompt_tokens: Some(12),
completion_tokens: Some(3),
completion_tokens_details: Some(OpenRouterCompletionTokenDetails {
reasoning_tokens: Some(1),
}),
}),
};
let events = openrouter_events_from_chat_completion(&request, response)
.expect("response should normalize");
assert!(matches!(
&events[0].payload,
EpiphanyOpenAiStreamPayload::TextDelta { text }
if text == r#"{"status":"ok"}"#
));
assert!(matches!(
&events[1].payload,
EpiphanyOpenAiStreamPayload::Completed { receipt }
if receipt.transport.as_deref() == Some("openrouter-chat-completions")
&& receipt.input_tokens == Some(12)
&& receipt.reasoning_output_tokens == Some(1)
));
}
#[test]
fn maps_typed_request_to_responses_body_without_codex_protocol_payload() {
let mut request = EpiphanyOpenAiModelRequest::new(

View file

@ -311,11 +311,12 @@ async fn main() -> Result<()> {
});
let openai_request =
epiphany_openai_runtime::openai_request_from_model_request(&request);
let runtime_options = default_options(
let mut runtime_options = default_options(
options.store_path.clone(),
options.codex_home,
&openai_request,
);
runtime_options.provider_credential_path = options.provider_credential_path;
ensure_openai_runtime_ready(&runtime_options)?;
epiphany_core::ensure_runtime_session(
&runtime_options.store_path,
@ -381,6 +382,7 @@ struct ModelTurnCliOptions {
provider: String,
store_path: PathBuf,
codex_home: PathBuf,
provider_credential_path: Option<PathBuf>,
request_path: PathBuf,
session_id: Option<String>,
job_id: Option<String>,
@ -492,6 +494,7 @@ impl ModelTurnCliOptions {
request: &EpiphanyOpenAiModelRequest,
) -> EpiphanyOpenAiRuntimeOptions {
let mut options = default_options(self.store_path, self.codex_home, request);
options.provider_credential_path = self.provider_credential_path;
if let Some(session_id) = self.session_id {
options.session_id = session_id;
}
@ -512,6 +515,7 @@ struct SmokeCliOptions {
provider: String,
store_path: PathBuf,
codex_home: PathBuf,
provider_credential_path: Option<PathBuf>,
}
#[derive(Clone)]
@ -519,6 +523,7 @@ struct RunWorkerCliOptions {
provider: String,
store_path: PathBuf,
codex_home: PathBuf,
provider_credential_path: Option<PathBuf>,
mcp_config: Option<PathBuf>,
job_id: String,
model: String,
@ -542,6 +547,7 @@ struct ToolFollowupTurnCliOptions {
provider: String,
store_path: PathBuf,
codex_home: PathBuf,
provider_credential_path: Option<PathBuf>,
request_id: String,
followup_request_id: String,
session_id: Option<String>,
@ -558,6 +564,7 @@ impl ToolFollowupTurnCliOptions {
) -> EpiphanyOpenAiRuntimeOptions {
let openai_request = epiphany_openai_runtime::openai_request_from_model_request(request);
let mut options = default_options(self.store_path, self.codex_home, &openai_request);
options.provider_credential_path = self.provider_credential_path;
if let Some(session_id) = self.session_id {
options.session_id = session_id;
}
@ -578,6 +585,7 @@ fn parse_model_turn_options(args: Vec<String>) -> Result<ModelTurnCliOptions> {
let mut provider = DEFAULT_PROVIDER.to_string();
let mut store_path = PathBuf::from(DEFAULT_STORE);
let mut codex_home = default_codex_home()?;
let mut provider_credential_path = None;
let mut request_path = None;
let mut session_id = None;
let mut job_id = None;
@ -590,6 +598,12 @@ fn parse_model_turn_options(args: Vec<String>) -> Result<ModelTurnCliOptions> {
"--provider" => provider = next_value(&mut iter, "--provider")?,
"--store" => store_path = PathBuf::from(next_value(&mut iter, "--store")?),
"--codex-home" => codex_home = PathBuf::from(next_value(&mut iter, "--codex-home")?),
"--provider-credential" => {
provider_credential_path = Some(PathBuf::from(next_value(
&mut iter,
"--provider-credential",
)?))
}
"--request" => request_path = Some(PathBuf::from(next_value(&mut iter, "--request")?)),
"--session-id" => session_id = Some(next_value(&mut iter, "--session-id")?),
"--job-id" => job_id = Some(next_value(&mut iter, "--job-id")?),
@ -608,6 +622,7 @@ fn parse_model_turn_options(args: Vec<String>) -> Result<ModelTurnCliOptions> {
provider,
store_path,
codex_home,
provider_credential_path,
request_path: request_path.context("model-turn requires --request")?,
session_id,
job_id,
@ -621,6 +636,7 @@ fn parse_run_worker_options(args: Vec<String>) -> Result<RunWorkerCliOptions> {
let mut provider = DEFAULT_PROVIDER.to_string();
let mut store_path = PathBuf::from(DEFAULT_STORE);
let mut codex_home = default_codex_home()?;
let mut provider_credential_path = None;
let mut mcp_config = None;
let mut job_id = None;
let mut model = default_worker_model();
@ -637,6 +653,12 @@ fn parse_run_worker_options(args: Vec<String>) -> Result<RunWorkerCliOptions> {
"--provider" => provider = next_value(&mut iter, "--provider")?,
"--store" => store_path = PathBuf::from(next_value(&mut iter, "--store")?),
"--codex-home" => codex_home = PathBuf::from(next_value(&mut iter, "--codex-home")?),
"--provider-credential" => {
provider_credential_path = Some(PathBuf::from(next_value(
&mut iter,
"--provider-credential",
)?))
}
"--mcp-config" => {
mcp_config = Some(PathBuf::from(next_value(&mut iter, "--mcp-config")?))
}
@ -666,6 +688,7 @@ fn parse_run_worker_options(args: Vec<String>) -> Result<RunWorkerCliOptions> {
provider,
store_path,
codex_home,
provider_credential_path,
mcp_config,
job_id: job_id.context("run-worker requires --job-id")?,
model,
@ -931,6 +954,7 @@ async fn run_worker_launch_with_tool_continuation(
let openai_options = EpiphanyOpenAiRuntimeOptions {
store_path: options.store_path.clone(),
codex_home: options.codex_home.clone(),
provider_credential_path: options.provider_credential_path.clone(),
session_id: worker_model_session_id(&launch_request.job_id),
job_id: format!("openai-worker-{}", launch_request.job_id),
objective: format!(
@ -1330,6 +1354,31 @@ mod tests {
Ok(())
}
#[test]
fn worker_cli_binds_openrouter_model_and_credential_explicitly() -> Result<()> {
let options = parse_run_worker_options(vec![
"--provider".into(),
"openrouter".into(),
"--provider-credential".into(),
"/run/credentials/epiphany/openrouter-api-key".into(),
"--model".into(),
"stealth/ox-alpha".into(),
"--job-id".into(),
"job-ox".into(),
"--activation-token-sha256".into(),
"b".repeat(64),
])?;
require_supported_provider(&options.provider)?;
assert_eq!(options.provider, "openrouter");
assert_eq!(options.model, "stealth/ox-alpha");
assert_eq!(
options.provider_credential_path.as_deref(),
Some(Path::new("/run/credentials/epiphany/openrouter-api-key"))
);
Ok(())
}
#[test]
fn model_turn_json_ingress_accepts_published_native_object() -> Result<()> {
let request = parse_model_turn_request_json(
@ -1355,7 +1404,7 @@ mod tests {
fn model_turn_json_ingress_refuses_caller_authored_provider_object() {
let error = parse_model_turn_request_json(
r#"{
"schema_id":"epiphany.openai_model_request.v0",
"schema_id":"epiphany.openai_model_request.v1",
"request_id":"request-json-provider",
"conversation_id":"conversation-json-provider",
"model":"gpt-test",
@ -1463,6 +1512,7 @@ mod tests {
&EpiphanyOpenAiRuntimeOptions {
store_path: store.to_path_buf(),
codex_home: PathBuf::from(".codex"),
provider_credential_path: None,
session_id,
job_id,
objective: format!("Run model execution for {worker_job_id}."),
@ -1965,6 +2015,7 @@ async fn run_worker_options(
EpiphanyWorkerRuntimeOptions {
store_path: options.store_path,
codex_home: options.codex_home,
provider_credential_path: options.provider_credential_path,
provider: options.provider,
job_id: options.job_id,
model: options.model,
@ -2017,6 +2068,7 @@ fn parse_tool_followup_turn_options(args: Vec<String>) -> Result<ToolFollowupTur
let mut provider = DEFAULT_PROVIDER.to_string();
let mut store_path = PathBuf::from(DEFAULT_STORE);
let mut codex_home = default_codex_home()?;
let mut provider_credential_path = None;
let mut request_id = None;
let mut followup_request_id = None;
let mut session_id = None;
@ -2030,6 +2082,12 @@ fn parse_tool_followup_turn_options(args: Vec<String>) -> Result<ToolFollowupTur
"--provider" => provider = next_value(&mut iter, "--provider")?,
"--store" => store_path = PathBuf::from(next_value(&mut iter, "--store")?),
"--codex-home" => codex_home = PathBuf::from(next_value(&mut iter, "--codex-home")?),
"--provider-credential" => {
provider_credential_path = Some(PathBuf::from(next_value(
&mut iter,
"--provider-credential",
)?))
}
"--request-id" => request_id = Some(next_value(&mut iter, "--request-id")?),
"--followup-request-id" => {
followup_request_id = Some(next_value(&mut iter, "--followup-request-id")?)
@ -2051,6 +2109,7 @@ fn parse_tool_followup_turn_options(args: Vec<String>) -> Result<ToolFollowupTur
provider,
store_path,
codex_home,
provider_credential_path,
request_id: request_id.context("tool-followup-turn requires --request-id")?,
followup_request_id: followup_request_id
.unwrap_or_else(|| format!("tool-followup-{}", Uuid::new_v4())),
@ -2069,12 +2128,19 @@ fn parse_smoke_options(args: Vec<String>) -> Result<SmokeCliOptions> {
Uuid::new_v4()
));
let mut codex_home = default_codex_home()?;
let mut provider_credential_path = None;
let mut iter = args.into_iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--provider" => provider = next_value(&mut iter, "--provider")?,
"--store" => store_path = PathBuf::from(next_value(&mut iter, "--store")?),
"--codex-home" => codex_home = PathBuf::from(next_value(&mut iter, "--codex-home")?),
"--provider-credential" => {
provider_credential_path = Some(PathBuf::from(next_value(
&mut iter,
"--provider-credential",
)?))
}
other => return Err(anyhow!("unknown smoke argument: {other}")),
}
}
@ -2082,15 +2148,16 @@ fn parse_smoke_options(args: Vec<String>) -> Result<SmokeCliOptions> {
provider,
store_path,
codex_home,
provider_credential_path,
})
}
fn require_supported_provider(provider: &str) -> Result<()> {
if matches!(provider, "openai-codex" | "openai") {
if matches!(provider, "openai-codex" | "openai" | "openrouter") {
return Ok(());
}
Err(anyhow!(
"unsupported model runtime provider {provider:?}; current providers: openai-codex"
"unsupported model runtime provider {provider:?}; current providers: openai-codex, openrouter"
))
}
@ -2109,5 +2176,5 @@ fn now() -> String {
}
fn usage() -> &'static str {
"usage: epiphany-model-runtime <list-decisions|audit-decision|model-turn|run-worker|tool-followup|tool-followup-turn|smoke> [--provider openai-codex] [--store path] [--codex-home path] [--request path] [--request-id id] [--context-id id] [--followup-request-id id] [--output path] [--session-id id] [--job-id id] [--activation-token-sha256 hex] [--objective text] [--default-model model] [--output-last-message path] [--auto-tools --tool-adapter-bin path --mcp-config path --cwd path --max-tool-rounds n]"
"usage: epiphany-model-runtime <list-decisions|audit-decision|model-turn|run-worker|tool-followup|tool-followup-turn|smoke> [--provider openai-codex|openrouter] [--provider-credential path] [--store path] [--codex-home path] [--request path] [--request-id id] [--context-id id] [--followup-request-id id] [--output path] [--session-id id] [--job-id id] [--activation-token-sha256 hex] [--objective text] [--default-model model] [--output-last-message path] [--auto-tools --tool-adapter-bin path --mcp-config path --cwd path --max-tool-rounds n]"
}

View file

@ -102,6 +102,7 @@ async fn poll_once(options: &Options) -> Result<bool> {
let mut runner = NativePersonaModelRunner {
store_path: options.runtime_store.clone(),
codex_home: options.codex_home.clone(),
provider_credential_path: options.provider_credential_path.clone(),
provider: options.provider.clone(),
model: options.model.clone(),
};
@ -304,6 +305,7 @@ struct Options {
agent_store: PathBuf,
cultmesh_store: PathBuf,
codex_home: PathBuf,
provider_credential_path: Option<PathBuf>,
runtime_id: String,
provider: String,
model: String,
@ -350,6 +352,7 @@ impl Options {
agent_store: path("--agent-store")?,
cultmesh_store: path("--cultmesh-store")?,
codex_home: path("--codex-home")?,
provider_credential_path: values.get("--provider-credential").map(PathBuf::from),
_repo_root: path("--repo-root")?,
mouth_request_store: path("--mouth-request-store")?,
mouth_receipt_store: path("--mouth-receipt-store")?,
@ -443,6 +446,7 @@ mod tests {
agent_store: absent.join("agent.cc"),
cultmesh_store: absent.join("cultmesh.cc"),
codex_home: absent.join("codex-home"),
provider_credential_path: None,
runtime_id: "persona-reentry".into(),
provider: "test".into(),
model: "test-model".into(),

View file

@ -38,10 +38,13 @@ use epiphany_openai_adapter::EpiphanyOpenAiModelRequest;
use epiphany_openai_adapter::EpiphanyOpenAiStreamEvent;
use epiphany_openai_adapter::EpiphanyOpenAiStreamPayload;
use epiphany_openai_codex_spine::EpiphanyCodexOpenAiTransport;
use epiphany_openai_codex_spine::EpiphanyOpenRouterTransport;
use epiphany_openai_codex_spine::EpiphanyResponsesFrameObservation;
use epiphany_openai_codex_spine::OPENROUTER_SPINE_ADAPTER_ID;
use epiphany_openai_codex_spine::auth_manager;
pub use epiphany_openai_codex_spine::default_codex_home;
use epiphany_openai_codex_spine::status_from_auth_manager;
use epiphany_openai_codex_spine::status_from_static_api_key;
use epiphany_tool_adapter::EPIPHANY_TOOL_RUNTIME_ADAPTER_ID;
use epiphany_tool_adapter::EpiphanyToolInvocationIntent;
use epiphany_tool_adapter::EpiphanyToolInvocationReceipt;
@ -60,6 +63,29 @@ pub use persona_executor::*;
pub const OPENAI_RUNTIME_ROLE: &str = "openai-model-adapter";
pub const OPENAI_RUNTIME_SOURCE: &str = "epiphany-openai-runtime";
pub const DEFAULT_MODEL_PROVIDER: &str = "openai-codex";
pub const OPENROUTER_MODEL_PROVIDER: &str = "openrouter";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EpiphanyProviderProfile {
OpenAiCodex,
OpenRouter,
}
impl EpiphanyProviderProfile {
fn parse(provider: &str) -> Result<Self> {
match provider {
"openai-codex" | "openai" => Ok(Self::OpenAiCodex),
OPENROUTER_MODEL_PROVIDER => Ok(Self::OpenRouter),
_ => Err(anyhow!(
"unsupported model runtime provider {provider:?}; current providers: openai-codex, openrouter"
)),
}
}
fn streaming_supported(self) -> bool {
matches!(self, Self::OpenAiCodex)
}
}
pub fn worker_model_session_id(worker_job_id: &str) -> String {
format!("openai-worker-session-{worker_job_id}")
@ -69,6 +95,7 @@ pub fn worker_model_session_id(worker_job_id: &str) -> String {
pub struct EpiphanyOpenAiRuntimeOptions {
pub store_path: PathBuf,
pub codex_home: PathBuf,
pub provider_credential_path: Option<PathBuf>,
pub session_id: String,
pub job_id: String,
pub objective: String,
@ -95,6 +122,7 @@ pub struct EpiphanyOpenAiRuntimeRunSummary {
pub struct EpiphanyWorkerRuntimeOptions {
pub store_path: PathBuf,
pub codex_home: PathBuf,
pub provider_credential_path: Option<PathBuf>,
pub provider: String,
pub job_id: String,
pub model: String,
@ -123,7 +151,8 @@ async fn run_openai_model_turn_bound(
request: EpiphanyOpenAiModelRequest,
) -> Result<EpiphanyOpenAiRuntimeRunSummary> {
ensure_openai_runtime_ready(&options)?;
let auth_manager = auth_manager(options.codex_home.clone());
let provider = model_request.provider.clone();
let profile = EpiphanyProviderProfile::parse(&provider)?;
open_runtime_model_execution(
&options.store_path,
RuntimeSpineSessionOptions {
@ -176,75 +205,158 @@ async fn run_openai_model_turn_bound(
},
)?;
let status = status_from_auth_manager(&auth_manager, options.default_model.clone(), true).await;
store_openai_status(&options.store_path, &status)?;
store_model_status(&options.store_path, &status, DEFAULT_MODEL_PROVIDER)?;
append_runtime_event(
&options.store_path,
RuntimeSpineEventOptions {
event_id: format!("event-openai-transport-ready-{}", options.job_id),
occurred_at: now(),
event_type: "openai.model_turn.transport_ready".to_string(),
source: OPENAI_RUNTIME_SOURCE.to_string(),
session_id: Some(options.session_id.clone()),
job_id: Some(options.job_id.clone()),
summary: format!(
"Codex/OpenAI transport ready for request {} with auth mode {:?}; opening Responses stream.",
request.request_id, status.auth_mode
),
},
)?;
let transport = EpiphanyCodexOpenAiTransport::openai(auth_manager);
let store_path_for_frames = options.store_path.clone();
let session_id_for_frames = options.session_id.clone();
let job_id_for_frames = options.job_id.clone();
let mut observed_frame_count = 0u64;
let events = match transport
.collect_model_events_with_frame_observer(request.clone(), move |observation| {
observed_frame_count += 1;
if should_record_frame_observation(observed_frame_count, &observation) {
let mut summary = format!(
"Observed Responses SSE frame {} kind={} recognized={}.",
observation.frame_sequence, observation.kind, observation.recognized
);
if let Some(preview) = observation.delta_preview.as_deref() {
summary.push_str(" deltaPreview=");
summary.push_str(preview);
}
let _ = append_runtime_event(
&store_path_for_frames,
RuntimeSpineEventOptions {
event_id: format!(
"event-openai-stream-frame-{}-{}",
job_id_for_frames, observation.frame_sequence
),
occurred_at: now(),
event_type: "openai.model_turn.stream_frame".to_string(),
source: OPENAI_RUNTIME_SOURCE.to_string(),
session_id: Some(session_id_for_frames.clone()),
job_id: Some(job_id_for_frames.clone()),
summary,
},
);
}
})
.await
{
Ok(events) => events,
Err(err) => {
let failure = EpiphanyOpenAiStreamEvent {
schema_id: epiphany_openai_adapter::OPENAI_ADAPTER_EVENT_SCHEMA_ID.to_string(),
request_id: request.request_id.clone(),
sequence: 0,
payload: EpiphanyOpenAiStreamPayload::Failed {
message: err.to_string(),
let (status, events) = match profile {
EpiphanyProviderProfile::OpenAiCodex => {
let auth_manager = auth_manager(options.codex_home.clone());
let status =
status_from_auth_manager(&auth_manager, options.default_model.clone(), true).await;
append_runtime_event(
&options.store_path,
RuntimeSpineEventOptions {
event_id: format!("event-openai-transport-ready-{}", options.job_id),
occurred_at: now(),
event_type: "openai.model_turn.transport_ready".to_string(),
source: OPENAI_RUNTIME_SOURCE.to_string(),
session_id: Some(options.session_id.clone()),
job_id: Some(options.job_id.clone()),
summary: format!(
"Codex/OpenAI Responses transport ready for request {} with auth mode {:?}.",
request.request_id, status.auth_mode
),
},
};
vec![failure]
)?;
let transport = EpiphanyCodexOpenAiTransport::openai(auth_manager);
let events = collect_transport_events(
transport.collect_model_events_with_frame_observer(
request.clone(),
frame_observer(&options),
),
&request,
)
.await;
(status, events)
}
EpiphanyProviderProfile::OpenRouter => {
let credential = read_static_provider_credential(
options.provider_credential_path.as_deref(),
OPENROUTER_MODEL_PROVIDER,
)?;
let status = status_from_static_api_key(
OPENROUTER_SPINE_ADAPTER_ID,
options.default_model.clone(),
);
append_runtime_event(
&options.store_path,
RuntimeSpineEventOptions {
event_id: format!("event-openrouter-transport-ready-{}", options.job_id),
occurred_at: now(),
event_type: "openrouter.model_turn.transport_ready".to_string(),
source: OPENAI_RUNTIME_SOURCE.to_string(),
session_id: Some(options.session_id.clone()),
job_id: Some(options.job_id.clone()),
summary: format!(
"OpenRouter Chat Completions transport ready for request {} with a dedicated file credential.",
request.request_id
),
},
)?;
let transport = EpiphanyOpenRouterTransport::new(credential)?;
let events = collect_transport_events(
transport.collect_model_events_with_frame_observer(
request.clone(),
frame_observer(&options),
),
&request,
)
.await;
(status, events)
}
};
record_openai_events(&options.store_path, &options, &request, &events)
store_openai_status(&options.store_path, &status)?;
store_model_status(
&options.store_path,
&status,
&provider,
profile.streaming_supported(),
true,
)?;
record_openai_events(&options.store_path, &options, &request, &provider, &events)
}
fn frame_observer(
options: &EpiphanyOpenAiRuntimeOptions,
) -> impl FnMut(EpiphanyResponsesFrameObservation) + use<> {
let store_path = options.store_path.clone();
let session_id = options.session_id.clone();
let job_id = options.job_id.clone();
let mut observed_frame_count = 0u64;
move |observation| {
observed_frame_count += 1;
if should_record_frame_observation(observed_frame_count, &observation) {
let mut summary = format!(
"Observed provider frame {} kind={} recognized={}.",
observation.frame_sequence, observation.kind, observation.recognized
);
if let Some(preview) = observation.delta_preview.as_deref() {
summary.push_str(" deltaPreview=");
summary.push_str(preview);
}
let _ = append_runtime_event(
&store_path,
RuntimeSpineEventOptions {
event_id: format!(
"event-model-stream-frame-{}-{}",
job_id, observation.frame_sequence
),
occurred_at: now(),
event_type: "model.turn.stream_frame".to_string(),
source: OPENAI_RUNTIME_SOURCE.to_string(),
session_id: Some(session_id.clone()),
job_id: Some(job_id.clone()),
summary,
},
);
}
}
}
async fn collect_transport_events<F>(
future: F,
request: &EpiphanyOpenAiModelRequest,
) -> Vec<EpiphanyOpenAiStreamEvent>
where
F: std::future::Future<Output = Result<Vec<EpiphanyOpenAiStreamEvent>>>,
{
match future.await {
Ok(events) => events,
Err(err) => vec![EpiphanyOpenAiStreamEvent {
schema_id: epiphany_openai_adapter::OPENAI_ADAPTER_EVENT_SCHEMA_ID.to_string(),
request_id: request.request_id.clone(),
sequence: 0,
payload: EpiphanyOpenAiStreamPayload::Failed {
message: err.to_string(),
},
}],
}
}
fn read_static_provider_credential(path: Option<&Path>, provider: &str) -> Result<String> {
let path = path.ok_or_else(|| {
anyhow!("model provider {provider:?} requires an explicit credential file")
})?;
let raw = std::fs::read_to_string(path).with_context(|| {
format!(
"failed to read {provider} credential file {}",
path.display()
)
})?;
let credential = raw.trim();
if credential.is_empty() || raw.trim_matches(['\r', '\n']) != credential {
return Err(anyhow!(
"model provider {provider:?} credential file is empty or contains surrounding whitespace"
));
}
Ok(credential.to_string())
}
fn should_record_frame_observation(
@ -281,7 +393,7 @@ pub async fn run_model_turn(
options: EpiphanyOpenAiRuntimeOptions,
request: EpiphanyModelRequest,
) -> Result<EpiphanyOpenAiRuntimeRunSummary> {
require_openai_provider(provider)?;
EpiphanyProviderProfile::parse(provider)?;
if !provider_matches_request(provider, &request.provider) {
return Err(anyhow!(
"model request provider {:?} does not match selected provider {:?}",
@ -299,7 +411,7 @@ pub async fn run_tool_followup_model_turn(
original_request_id: &str,
followup_request_id: &str,
) -> Result<EpiphanyOpenAiRuntimeRunSummary> {
require_openai_provider(provider)?;
EpiphanyProviderProfile::parse(provider)?;
let request = build_tool_followup_model_request(
&options.store_path,
original_request_id,
@ -340,6 +452,7 @@ where
let openai_options = EpiphanyOpenAiRuntimeOptions {
store_path: options.store_path.clone(),
codex_home: options.codex_home,
provider_credential_path: options.provider_credential_path,
session_id: worker_model_session_id(&launch_request.job_id),
job_id: format!("openai-worker-{}", launch_request.job_id),
objective: format!(
@ -397,11 +510,12 @@ fn record_openai_events(
store_path: impl AsRef<Path>,
options: &EpiphanyOpenAiRuntimeOptions,
request: &EpiphanyOpenAiModelRequest,
provider: &str,
events: &[EpiphanyOpenAiStreamEvent],
) -> Result<EpiphanyOpenAiRuntimeRunSummary> {
let store_path = store_path.as_ref();
let events = compact_openai_events_for_storage(events);
let tool_intents = tool_invocation_intents_from_openai_events(DEFAULT_MODEL_PROVIDER, &events);
let tool_intents = tool_invocation_intents_from_openai_events(provider, &events);
for intent in &tool_intents {
put_runtime_tool_execution_intent(
store_path,
@ -417,7 +531,7 @@ fn record_openai_events(
let mut cache = runtime_spine_cache(store_path)?;
cache.pull_all_backing_stores()?;
for event in &events {
let model_event = model_event_from_openai_event(DEFAULT_MODEL_PROVIDER, event);
let model_event = model_event_from_openai_event(provider, event);
cache.put(
model_event_key(&model_event.request_id, model_event.sequence),
&model_event,
@ -524,7 +638,13 @@ pub fn record_native_model_events(
events: &[EpiphanyOpenAiStreamEvent],
) -> Result<EpiphanyOpenAiRuntimeRunSummary> {
let provider_request = epiphany_openai_adapter::request_from_native(request);
record_openai_events(store_path, options, &provider_request, events)
record_openai_events(
store_path,
options,
&provider_request,
&request.provider,
events,
)
}
fn compact_openai_events_for_storage(
@ -933,6 +1053,8 @@ pub fn store_model_status(
store_path: impl AsRef<Path>,
status: &EpiphanyOpenAiAdapterStatus,
provider: &str,
streaming_supported: bool,
provider_transport_attached: bool,
) -> Result<()> {
let mut cache = runtime_spine_cache(store_path)?;
cache.pull_all_backing_stores()?;
@ -941,8 +1063,8 @@ pub fn store_model_status(
adapter_id: status.adapter_id.clone(),
provider: provider.to_string(),
default_model: status.default_model.clone(),
streaming_supported: true,
provider_transport_attached: status.codex_transport_attached,
streaming_supported,
provider_transport_attached,
};
cache.put(status.adapter_id.clone(), &status)?;
Ok(())
@ -1186,6 +1308,7 @@ pub fn default_options(
EpiphanyOpenAiRuntimeOptions {
store_path,
codex_home,
provider_credential_path: None,
session_id: format!("openai-session-{}", request.conversation_id),
job_id: format!("openai-job-{}", request.request_id),
objective: format!("Run typed OpenAI model request {}", request.request_id),
@ -1533,15 +1656,6 @@ fn arguments_are_invocation_ready(arguments: &str) -> bool {
)
}
fn require_openai_provider(provider: &str) -> Result<()> {
if matches!(provider, "openai-codex" | "openai") {
return Ok(());
}
Err(anyhow!(
"unsupported model runtime provider {provider:?}; current providers: openai-codex"
))
}
fn provider_matches_request(selected: &str, requested: &str) -> bool {
selected == requested || (selected == "openai" && requested == DEFAULT_MODEL_PROVIDER)
}
@ -3807,7 +3921,8 @@ mod tests {
payload: EpiphanyOpenAiStreamPayload::Completed { receipt },
}];
let summary = record_openai_events(&store, &options, &request, &events)?;
let summary =
record_openai_events(&store, &options, &request, DEFAULT_MODEL_PROVIDER, &events)?;
assert_eq!(summary.verdict, "pass");
assert_eq!(assistant_text_from_openai_events(&store, "req-1")?, "");
@ -4548,7 +4663,8 @@ mod tests {
payload: EpiphanyOpenAiStreamPayload::Completed { receipt },
},
];
let summary = record_openai_events(&store, &options, &request, &events)?;
let summary =
record_openai_events(&store, &options, &request, DEFAULT_MODEL_PROVIDER, &events)?;
let intent_id = summary
.tool_intent_ids
.first()

View file

@ -67,6 +67,7 @@ impl PersonaModelExecutionPlan {
pub struct NativePersonaModelRunner {
pub store_path: PathBuf,
pub codex_home: PathBuf,
pub provider_credential_path: Option<PathBuf>,
pub provider: String,
pub model: String,
}
@ -98,6 +99,7 @@ impl PersonaModelRunner for NativePersonaModelRunner {
let options = EpiphanyOpenAiRuntimeOptions {
store_path: self.store_path.clone(),
codex_home: self.codex_home.clone(),
provider_credential_path: self.provider_credential_path.clone(),
session_id: format!("persona-turn-{turn_id}"),
job_id: format!("persona-{stage}-{turn_id}"),
objective: format!("Run Persona {stage} stage for {turn_id}"),

View file

@ -3,9 +3,9 @@
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.openai-model-request.schema.json",
"title": "Epiphany OpenAI Model Request",
"type": "object",
"required": ["schema_id", "request_id", "conversation_id", "model", "instructions", "input"],
"required": ["schema_id", "request_id", "conversation_id", "model", "instructions", "input", "provider_id", "wire_dialect"],
"properties": {
"schema_id": { "const": "epiphany.openai_model_request.v0" },
"schema_id": { "const": "epiphany.openai_model_request.v1" },
"request_id": { "type": "string" },
"conversation_id": { "type": "string" },
"model": { "type": "string" },
@ -17,12 +17,37 @@
"reasoning_effort": { "type": ["string", "null"] },
"reasoning_summary": { "type": ["string", "null"] },
"service_tier": { "type": ["string", "null"] },
"output_contract_id": { "type": ["string", "null"] }
"output_contract_id": { "type": ["string", "null"] },
"previous_response_id": { "type": ["string", "null"] },
"tools": {
"type": "array",
"items": { "$ref": "#/$defs/tool_definition" }
},
"output_schema_json": { "type": ["string", "null"] },
"provider_id": { "enum": ["openai-codex", "openai", "openrouter"] },
"wire_dialect": { "enum": ["Responses", "ChatCompletionsTerminalTool"] }
},
"additionalProperties": false,
"$defs": {
"input_item": {
"oneOf": [
{
"type": "object",
"required": ["ToolCall"],
"properties": {
"ToolCall": {
"type": "object",
"required": ["call_id", "name", "arguments"],
"properties": {
"call_id": { "type": "string" },
"name": { "type": "string" },
"arguments": { "type": "string" }
},
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["UserText"],
@ -66,6 +91,16 @@
"additionalProperties": false
}
]
},
"tool_definition": {
"type": "object",
"required": ["name", "description", "parameters_json"],
"properties": {
"name": { "type": "string" },
"description": { "type": "string" },
"parameters_json": { "type": "string" }
},
"additionalProperties": false
}
}
}

View file

@ -208,8 +208,8 @@
"wireContracts": [
"cultnet.schema.v0"
],
"schemaVersion": "epiphany.openai_model_request.v0",
"documentType": "epiphany.openai_model_request.v0",
"schemaVersion": "epiphany.openai_model_request.v1",
"documentType": "epiphany.openai_model_request.v1",
"title": "Epiphany OpenAI Model Request",
"path": "epiphany.openai-model-request.schema.json"
},