From 71eca3889e7b4e885d1ea963d14928ddbab61e45 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 13 May 2026 05:28:13 +0100 Subject: [PATCH] Add native OpenAI spine CLI --- epiphany-openai-codex-spine/Cargo.toml | 14 ++ .../src/bin/epiphany-openai-spine.rs | 154 ++++++++++++++++++ notes/codex-auth-spine-inventory.md | 15 ++ notes/fresh-workspace-handoff.md | 10 ++ state/ledgers.msgpack | Bin 81653 -> 82005 bytes state/map.yaml | 4 +- vendor/codex/codex-rs/Cargo.lock | 3 + 7 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 epiphany-openai-codex-spine/src/bin/epiphany-openai-spine.rs diff --git a/epiphany-openai-codex-spine/Cargo.toml b/epiphany-openai-codex-spine/Cargo.toml index 5c4b24bd..95de66e0 100644 --- a/epiphany-openai-codex-spine/Cargo.toml +++ b/epiphany-openai-codex-spine/Cargo.toml @@ -19,3 +19,17 @@ codex-model-provider-info = { path = "../vendor/codex/codex-rs/model-provider-in codex-protocol = { path = "../vendor/codex/codex-rs/protocol" } epiphany-openai-adapter = { path = "../epiphany-openai-adapter" } futures = "0.3" +serde = "1" +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } + +[[bin]] +name = "epiphany-openai-spine" +path = "src/bin/epiphany-openai-spine.rs" + +[patch.crates-io] +tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "132f5b39c862e3a970f731d709608b3e6276d5f6" } +tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "9200079d3b54a1ff51072e24d81fd354f085156f" } + +[patch."ssh://git@github.com/openai-oss-forks/tungstenite-rs.git"] +tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "9200079d3b54a1ff51072e24d81fd354f085156f" } diff --git a/epiphany-openai-codex-spine/src/bin/epiphany-openai-spine.rs b/epiphany-openai-codex-spine/src/bin/epiphany-openai-spine.rs new file mode 100644 index 00000000..3776bb18 --- /dev/null +++ b/epiphany-openai-codex-spine/src/bin/epiphany-openai-spine.rs @@ -0,0 +1,154 @@ +use std::env; +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use codex_login::AuthCredentialsStoreMode; +use codex_login::AuthManager; +use codex_login::CodexAuth; +use epiphany_openai_adapter::EpiphanyOpenAiInputItem; +use epiphany_openai_adapter::EpiphanyOpenAiModelRequest; +use epiphany_openai_codex_spine::EpiphanyCodexOpenAiTransport; +use epiphany_openai_codex_spine::responses_request_from_epiphany; +use epiphany_openai_codex_spine::status_from_auth_manager; +use epiphany_openai_codex_spine::status_from_codex_auth; +use serde_json::json; + +#[tokio::main] +async fn main() -> Result<()> { + let mut args = env::args().skip(1); + let command = args.next().unwrap_or_else(|| "usage".to_string()); + match command.as_str() { + "status" => { + let options = parse_common_options(args.collect())?; + let auth_manager = auth_manager(options.codex_home); + let status = status_from_auth_manager( + &auth_manager, + options.default_model, + /*supports_websockets*/ true, + ) + .await; + print_json(&status)?; + } + "model-turn" => { + let options = parse_model_turn_options(args.collect())?; + let request_text = fs::read_to_string(&options.request_path) + .with_context(|| format!("failed to read {}", options.request_path.display()))?; + let request: EpiphanyOpenAiModelRequest = serde_json::from_str(&request_text) + .with_context(|| format!("failed to parse {}", options.request_path.display()))?; + let transport = EpiphanyCodexOpenAiTransport::openai(auth_manager(options.codex_home)); + let events = transport.collect_model_events(request).await?; + print_json(&events)?; + } + "smoke" => { + let auth = CodexAuth::from_api_key("test-key"); + let status = status_from_codex_auth(Some(&auth), Some("gpt-5.4".to_string()), true); + let mut request = EpiphanyOpenAiModelRequest::new( + "smoke-request", + "smoke-conversation", + "gpt-5.4", + "Answer with one sentence.", + ); + request.input.push(EpiphanyOpenAiInputItem::UserText { + text: "smoke".to_string(), + }); + let mapped = responses_request_from_epiphany(request)?; + print_json(&json!({ + "status": status, + "mappedModel": mapped.model, + "mappedInputItems": mapped.input.len(), + "transport": "not-opened" + }))?; + } + _ => { + return Err(anyhow!( + "usage: epiphany-openai-spine [--codex-home path] [--default-model model] [--request path]" + )); + } + } + Ok(()) +} + +struct CommonOptions { + codex_home: PathBuf, + default_model: Option, +} + +struct ModelTurnOptions { + codex_home: PathBuf, + request_path: PathBuf, +} + +fn parse_common_options(args: Vec) -> Result { + let mut codex_home = default_codex_home()?; + let mut default_model = None; + let mut iter = args.into_iter(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--codex-home" => { + codex_home = PathBuf::from(next_value(&mut iter, "--codex-home")?); + } + "--default-model" => { + default_model = Some(next_value(&mut iter, "--default-model")?); + } + other => return Err(anyhow!("unknown status argument: {other}")), + } + } + Ok(CommonOptions { + codex_home, + default_model, + }) +} + +fn parse_model_turn_options(args: Vec) -> Result { + let mut codex_home = default_codex_home()?; + let mut request_path = None; + let mut iter = args.into_iter(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--codex-home" => { + codex_home = PathBuf::from(next_value(&mut iter, "--codex-home")?); + } + "--request" => { + request_path = Some(PathBuf::from(next_value(&mut iter, "--request")?)); + } + other => return Err(anyhow!("unknown model-turn argument: {other}")), + } + } + Ok(ModelTurnOptions { + codex_home, + request_path: request_path.context("model-turn requires --request")?, + }) +} + +fn next_value(iter: &mut impl Iterator, name: &str) -> Result { + iter.next() + .ok_or_else(|| anyhow!("{name} requires a value")) +} + +fn default_codex_home() -> Result { + if let Ok(path) = env::var("CODEX_HOME") { + return Ok(PathBuf::from(path)); + } + let home = env::var("USERPROFILE") + .or_else(|_| env::var("HOME")) + .context("CODEX_HOME is unset and no home directory environment variable exists")?; + Ok(PathBuf::from(home).join(".codex")) +} + +fn auth_manager(codex_home: PathBuf) -> Arc { + AuthManager::shared( + codex_home, + /*enable_codex_api_key_env*/ true, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) +} + +fn print_json(value: &T) -> Result<()> { + println!("{}", serde_json::to_string_pretty(value)?); + Ok(()) +} diff --git a/notes/codex-auth-spine-inventory.md b/notes/codex-auth-spine-inventory.md index de6338ed..7c7f9a87 100644 --- a/notes/codex-auth-spine-inventory.md +++ b/notes/codex-auth-spine-inventory.md @@ -141,5 +141,20 @@ contracts in its hello/schema catalog. This is the contract bridge the native runtime should consume next. Do not add another JSON-RPC model endpoint and call it progress. +The first native operator/debug edge now exists as the +`epiphany-openai-spine` binary in `epiphany-openai-codex-spine`. It can report +typed adapter status and consume a serialized `EpiphanyOpenAiModelRequest` +document for a model turn without going through Codex app-server JSON-RPC. This +is not the final CultNet daemon. It is a buildable extracted edge that proves +the spine can be called from Epiphany-owned code and gives the next cut a place +to route through while the old `thread/epiphany/*` model path is starved. + +The bad news is useful: the current spine still drags in `codex-api` and its +large transport dependency stack. That is tolerable as a sealed compatibility +reliquary, but not as the final shape. The next purification should shrink the +surviving Codex dependency surface to credential loading/refresh, provider +auth, model selection, and the smallest OpenAI Responses HTTP call that keeps +Codex subscription compatibility. + The point is ownership: Epiphany calls a model adapter; it does not live inside the Codex host brain. diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index ee1221e3..2da9f7cd 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -567,6 +567,16 @@ plus the coordinator-owned model request contract. The native runtime should consume that contract next; do not make a new JSON-RPC model endpoint and pretend the whale got lighter. +The `epiphany-openai-spine` binary is now the first native edge for that +wrapper. It can print typed adapter status and consume a serialized +`EpiphanyOpenAiModelRequest` document for a model turn outside Codex +app-server. Treat its JSON as CLI/file-edge serialization of typed documents, +not internal data cargo. The current wrapper still pulls `codex-api` and a +large dependency stack, so the next real cut is not celebration; it is routing +native runtime/CultNet model calls through this spine and then shrinking the +surviving Codex dependency surface to auth, provider/model routing, and the +smallest OpenAI Responses call needed for subscription compatibility. + Also: MCP itself is allowed to be JSON. The target is not "replace MCP JSON"; the target is an Epiphany-owned boundary that speaks typed Epiphany intent/result/receipt documents internally and normal MCP JSON-RPC externally. diff --git a/state/ledgers.msgpack b/state/ledgers.msgpack index be1f785aac8a21e79f2b4a50987c759e86bc211d..b472b069ad7b26bdcbad28f848c9d1093f3ae992 100644 GIT binary patch delta 364 zcmZXQO-chn5QQ1hGq})2L2x&n#QZ2k1VdI4Nkm<_Yo;riVv?S=yE-PT1mXb%!2`H- zCkP_s8hR6#f*mCyxO`QwKHjU3&-J(2#%*EkXzOX`K8oJwi|Az$MTNu7n+JBUm2zcI zlQNxN zVV+_9OH5KsvKW2>@DdkxT0yd8i9f7wV-Y$yW=K2(xm6kKHPaAFEkEE6GT4xZbRW#X hJ8-Ebol!mL6_P+