diff --git a/epiphany-core/src/bin/epiphany-mvp-coordinator.rs b/epiphany-core/src/bin/epiphany-mvp-coordinator.rs index 7b6f4832..d10bd915 100644 --- a/epiphany-core/src/bin/epiphany-mvp-coordinator.rs +++ b/epiphany-core/src/bin/epiphany-mvp-coordinator.rs @@ -71,6 +71,8 @@ struct Args { model_runtime_bin: PathBuf, tool_adapter_bin: PathBuf, model_provider: String, + model: String, + provider_credential_path: Option, thread_id: Option, objective: Option, 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 { &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 { &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 { &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 { &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 { &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 { &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 { &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 { &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 { &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(), diff --git a/epiphany-core/src/bin/epiphany-swarm.rs b/epiphany-core/src/bin/epiphany-swarm.rs index ee73da53..5d351203 100644 --- a/epiphany-core/src/bin/epiphany-swarm.rs +++ b/epiphany-core/src/bin/epiphany-swarm.rs @@ -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, diff --git a/epiphany-core/src/reasoning_context.rs b/epiphany-core/src/reasoning_context.rs index ac39feff..45ae2444 100644 --- a/epiphany-core/src/reasoning_context.rs +++ b/epiphany-core/src/reasoning_context.rs @@ -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<()> { diff --git a/epiphany-core/src/resident_readiness.rs b/epiphany-core/src/resident_readiness.rs index a09c34cb..311fab1f 100644 --- a/epiphany-core/src/resident_readiness.rs +++ b/epiphany-core/src/resident_readiness.rs @@ -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, diff --git a/epiphany-core/src/resident_self.rs b/epiphany-core/src/resident_self.rs index 40022b63..6dbf15fa 100644 --- a/epiphany-core/src/resident_self.rs +++ b/epiphany-core/src/resident_self.rs @@ -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, 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 diff --git a/epiphany-core/src/runtime_spine.rs b/epiphany-core/src/runtime_spine.rs index e409f5df..fc4400de 100644 --- a/epiphany-core/src/runtime_spine.rs +++ b/epiphany-core/src/runtime_spine.rs @@ -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::()?; + responses_only.register_entry_type::()?; + 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(()) } } diff --git a/epiphany-openai-adapter/src/lib.rs b/epiphany-openai-adapter/src/lib.rs index 6054bc19..9b02de1d 100644 --- a/epiphany-openai-adapter/src/lib.rs +++ b/epiphany-openai-adapter/src/lib.rs @@ -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, #[cultcache(key = 12, default)] pub output_schema_json: Option, + #[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"); + } +} diff --git a/epiphany-openai-codex-spine/src/lib.rs b/epiphany-openai-codex-spine/src/lib.rs index ceb5e073..770aa8fd 100644 --- a/epiphany-openai-codex-spine/src/lib.rs +++ b/epiphany-openai-codex-spine/src/lib.rs @@ -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 { 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, +) -> 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, base_url: Option, @@ -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) -> Result { + 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> { + 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> { + 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::()?; + 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 { + 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::>>()?; + 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 { + 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, + model: Option, + choices: Vec, + usage: Option, +} + +#[derive(Debug, Deserialize)] +struct OpenRouterChatChoice { + message: OpenRouterChatMessage, +} + +#[derive(Debug, Deserialize)] +struct OpenRouterChatMessage { + content: Option, + #[serde(default)] + reasoning: Option, + #[serde(default)] + tool_calls: Vec, +} + +#[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, + completion_tokens: Option, + completion_tokens_details: Option, +} + +#[derive(Debug, Deserialize)] +struct OpenRouterCompletionTokenDetails { + reasoning_tokens: Option, +} + +fn openrouter_events_from_chat_completion( + request: &EpiphanyOpenAiModelRequest, + response: OpenRouterChatCompletionResponse, +) -> Result> { + 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::>(); + 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::(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, + 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 { + 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( diff --git a/epiphany-openai-runtime/src/bin/epiphany-openai-runtime.rs b/epiphany-openai-runtime/src/bin/epiphany-openai-runtime.rs index 9e3b2610..4c696e22 100644 --- a/epiphany-openai-runtime/src/bin/epiphany-openai-runtime.rs +++ b/epiphany-openai-runtime/src/bin/epiphany-openai-runtime.rs @@ -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, request_path: PathBuf, session_id: Option, job_id: Option, @@ -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, } #[derive(Clone)] @@ -519,6 +523,7 @@ struct RunWorkerCliOptions { provider: String, store_path: PathBuf, codex_home: PathBuf, + provider_credential_path: Option, mcp_config: Option, job_id: String, model: String, @@ -542,6 +547,7 @@ struct ToolFollowupTurnCliOptions { provider: String, store_path: PathBuf, codex_home: PathBuf, + provider_credential_path: Option, request_id: String, followup_request_id: String, session_id: Option, @@ -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) -> Result { 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) -> Result { "--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) -> Result { 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) -> Result { 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) -> Result { "--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) -> Result { 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) -> Result) -> Result 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) -> Result) -> Result { 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) -> Result { 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 [--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 [--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]" } diff --git a/epiphany-openai-runtime/src/bin/epiphany-persona-service.rs b/epiphany-openai-runtime/src/bin/epiphany-persona-service.rs index 46d5a93a..e22f040f 100644 --- a/epiphany-openai-runtime/src/bin/epiphany-persona-service.rs +++ b/epiphany-openai-runtime/src/bin/epiphany-persona-service.rs @@ -102,6 +102,7 @@ async fn poll_once(options: &Options) -> Result { 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, 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(), diff --git a/epiphany-openai-runtime/src/lib.rs b/epiphany-openai-runtime/src/lib.rs index a87d76d9..e47e2a35 100644 --- a/epiphany-openai-runtime/src/lib.rs +++ b/epiphany-openai-runtime/src/lib.rs @@ -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 { + 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, 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, pub provider: String, pub job_id: String, pub model: String, @@ -123,7 +151,8 @@ async fn run_openai_model_turn_bound( request: EpiphanyOpenAiModelRequest, ) -> Result { 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( + future: F, + request: &EpiphanyOpenAiModelRequest, +) -> Vec +where + F: std::future::Future>>, +{ + 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 { + 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 { - 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 { - 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, options: &EpiphanyOpenAiRuntimeOptions, request: &EpiphanyOpenAiModelRequest, + provider: &str, events: &[EpiphanyOpenAiStreamEvent], ) -> Result { 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 { 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, 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() diff --git a/epiphany-openai-runtime/src/persona_executor.rs b/epiphany-openai-runtime/src/persona_executor.rs index f3ba3734..abc531ab 100644 --- a/epiphany-openai-runtime/src/persona_executor.rs +++ b/epiphany-openai-runtime/src/persona_executor.rs @@ -67,6 +67,7 @@ impl PersonaModelExecutionPlan { pub struct NativePersonaModelRunner { pub store_path: PathBuf, pub codex_home: PathBuf, + pub provider_credential_path: Option, 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}"), diff --git a/schemas/cultnet/epiphany.openai-model-request.schema.json b/schemas/cultnet/epiphany.openai-model-request.schema.json index d7c8ad4b..eaaa522d 100644 --- a/schemas/cultnet/epiphany.openai-model-request.schema.json +++ b/schemas/cultnet/epiphany.openai-model-request.schema.json @@ -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 } } } diff --git a/schemas/cultnet/index.json b/schemas/cultnet/index.json index 14f7b4d8..a098eb2c 100644 --- a/schemas/cultnet/index.json +++ b/schemas/cultnet/index.json @@ -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" },