Add decision-auditable OpenRouter transport
This commit is contained in:
parent
7ea40ae7e5
commit
ab321b3434
14 changed files with 1061 additions and 103 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<()>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
Reference in a new issue