From fd9260c3c523163b0c9014e5f901290f50236359 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Tue, 17 Mar 2026 00:47:47 -0400 Subject: [PATCH] Add Codex endpoint support for OpenAI OAuth flow Route OpenAI OAuth users through the ChatGPT Codex backend API with required headers (ChatGPT-Account-Id, originator). The Codex endpoint requires streaming-only requests, omits unsupported fields (temperature, max_output_tokens, top_p), and uses a different error format. Also persists the account ID from OAuth tokens and updates CLI docs for `fabro ps -q`. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/docs/watermark | 2 +- docs/reference/cli.mdx | 5 +- lib/crates/fabro-cli/src/install.rs | 29 +++++-- lib/crates/fabro-llm/src/client.rs | 11 ++- lib/crates/fabro-llm/src/providers/common.rs | 2 + lib/crates/fabro-llm/src/providers/openai.rs | 82 +++++++++++++++----- lib/crates/fabro-openai-oauth/src/lib.rs | 5 +- 7 files changed, 105 insertions(+), 31 deletions(-) diff --git a/.claude/skills/docs/watermark b/.claude/skills/docs/watermark index c29af3c95..866b4eaab 100644 --- a/.claude/skills/docs/watermark +++ b/.claude/skills/docs/watermark @@ -1 +1 @@ -610aff744609268ae879a58acfe6c9a6bb0572e0 +e09a6de72fe7c69e8868abf5412c088bde05bece diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index d8c775720..7b04b1445 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -79,6 +79,8 @@ fabro ps # active runs only fabro ps -a # all runs including completed fabro ps --workflow deploy --label env=prod fabro ps --json +fabro ps -q # run IDs only, one per line +fabro ps -qa # all run IDs ``` The table shows run ID, status, workflow name, goal, and timing. @@ -91,6 +93,7 @@ The table shows run ID, status, workflow name, goal, and timing. | `--label ` | Filter by label (repeatable, AND semantics) | | `--orphans` | Include orphan directories (no `manifest.json`) | | `--json` | Output as JSON | +| `-q, --quiet` | Only display full run IDs, one per line (no headers or footers). Takes precedence over `--json`. | ## `fabro rm` @@ -361,7 +364,7 @@ fabro system df -v ## `fabro graph` -Render a workflow graph as SVG or PNG. Requires [Graphviz](https://graphviz.org) (`dot`) to be installed. +Render a workflow graph as SVG or PNG. Requires [Graphviz](https://graphviz.org) (`dot`) to be installed. SVG output includes styled defaults (teal node strokes, clean typography, transparent background) and automatic dark mode support via `prefers-color-scheme` media queries. ```bash fabro graph workflow.fabro diff --git a/lib/crates/fabro-cli/src/install.rs b/lib/crates/fabro-cli/src/install.rs index 4478a4424..8181a0bdf 100644 --- a/lib/crates/fabro-cli/src/install.rs +++ b/lib/crates/fabro-cli/src/install.rs @@ -280,14 +280,22 @@ fn detect_binary_on_path(binary: &str) -> bool { } /// Convert OAuth tokens to env var pairs for ~/.fabro/.env. -fn openai_oauth_env_pairs(access_token: &str, refresh_token: &str) -> Vec<(String, String)> { - vec![ +fn openai_oauth_env_pairs( + access_token: &str, + refresh_token: &str, + account_id: Option<&str>, +) -> Vec<(String, String)> { + let mut pairs = vec![ ("OPENAI_API_KEY".to_string(), access_token.to_string()), ( "OPENAI_REFRESH_TOKEN".to_string(), refresh_token.to_string(), ), - ] + ]; + if let Some(id) = account_id { + pairs.push(("CHATGPT_ACCOUNT_ID".to_string(), id.to_string())); + } + pairs } // --------------------------------------------------------------------------- @@ -681,9 +689,11 @@ pub async fn run_install() -> Result<()> { { Ok(tokens) => { tracing::info!("OpenAI OAuth browser flow completed"); + let account_id = fabro_openai_oauth::extract_account_id(&tokens); env_pairs.extend(openai_oauth_env_pairs( &tokens.access_token, &tokens.refresh_token, + account_id.as_deref(), )); configured_providers.push(Provider::OpenAi); openai_via_oauth = true; @@ -1005,22 +1015,29 @@ mod tests { #[test] fn openai_oauth_env_pairs_sets_api_key() { - let pairs = openai_oauth_env_pairs("tok", "ref"); + let pairs = openai_oauth_env_pairs("tok", "ref", None); assert!(pairs.contains(&("OPENAI_API_KEY".to_string(), "tok".to_string()))); } #[test] fn openai_oauth_env_pairs_sets_refresh_token() { - let pairs = openai_oauth_env_pairs("tok", "ref"); + let pairs = openai_oauth_env_pairs("tok", "ref", None); assert!(pairs.contains(&("OPENAI_REFRESH_TOKEN".to_string(), "ref".to_string()))); } #[test] fn openai_oauth_env_pairs_count() { - let pairs = openai_oauth_env_pairs("tok", "ref"); + let pairs = openai_oauth_env_pairs("tok", "ref", None); assert_eq!(pairs.len(), 2); } + #[test] + fn openai_oauth_env_pairs_with_account_id() { + let pairs = openai_oauth_env_pairs("tok", "ref", Some("acct_123")); + assert!(pairs.contains(&("CHATGPT_ACCOUNT_ID".to_string(), "acct_123".to_string()))); + assert_eq!(pairs.len(), 3); + } + // -- Session secret (server only) -- #[test] diff --git a/lib/crates/fabro-llm/src/client.rs b/lib/crates/fabro-llm/src/client.rs index 8ec45f146..50e6774f0 100644 --- a/lib/crates/fabro-llm/src/client.rs +++ b/lib/crates/fabro-llm/src/client.rs @@ -55,7 +55,16 @@ impl Client { } if let Ok(key) = std::env::var("OPENAI_API_KEY") { let mut adapter = providers::OpenAiAdapter::new(key); - if let Ok(base_url) = std::env::var("OPENAI_BASE_URL") { + if let Ok(account_id) = std::env::var("CHATGPT_ACCOUNT_ID") { + // Codex OAuth: route through chatgpt.com backend with required headers + adapter = adapter + .with_base_url("https://chatgpt.com/backend-api/codex") + .with_codex_mode(); + let mut headers = std::collections::HashMap::new(); + headers.insert("ChatGPT-Account-Id".to_string(), account_id); + headers.insert("originator".to_string(), "fabro".to_string()); + adapter = adapter.with_default_headers(headers); + } else if let Ok(base_url) = std::env::var("OPENAI_BASE_URL") { adapter = adapter.with_base_url(base_url); } if let Ok(org_id) = std::env::var("OPENAI_ORG_ID") { diff --git a/lib/crates/fabro-llm/src/providers/common.rs b/lib/crates/fabro-llm/src/providers/common.rs index 471ca6f1d..9d0fa1e97 100644 --- a/lib/crates/fabro-llm/src/providers/common.rs +++ b/lib/crates/fabro-llm/src/providers/common.rs @@ -19,6 +19,8 @@ pub fn parse_error_body( .get("error") .and_then(|e| e.get("message")) .and_then(serde_json::Value::as_str) + // Codex endpoint returns {"detail": "..."} instead of {"error": {"message": "..."}} + .or_else(|| v.get("detail").and_then(serde_json::Value::as_str)) .unwrap_or("Unknown error") .to_string(); let error_code = v diff --git a/lib/crates/fabro-llm/src/providers/openai.rs b/lib/crates/fabro-llm/src/providers/openai.rs index 1184984c7..153f635d8 100644 --- a/lib/crates/fabro-llm/src/providers/openai.rs +++ b/lib/crates/fabro-llm/src/providers/openai.rs @@ -21,6 +21,8 @@ pub struct Adapter { pub(crate) http: super::http_api::HttpApi, org_id: Option, project_id: Option, + /// When true, always use streaming (required by the Codex endpoint). + codex_mode: bool, } impl Adapter { @@ -30,9 +32,16 @@ impl Adapter { http: super::http_api::HttpApi::new(api_key, DEFAULT_BASE_URL), org_id: None, project_id: None, + codex_mode: false, } } + #[must_use] + pub fn with_codex_mode(mut self) -> Self { + self.codex_mode = true; + self + } + #[must_use] pub fn with_base_url(mut self, base_url: impl Into) -> Self { self.http.base_url = base_url.into(); @@ -83,6 +92,23 @@ impl Adapter { } req } + + /// Complete a request by streaming and collecting the final response. + /// Used for the Codex endpoint which requires `stream: true`. + async fn complete_via_stream(&self, request: &Request) -> Result { + use futures::StreamExt; + let mut event_stream = self.stream(request).await?; + let mut last_response: Option = None; + while let Some(event) = event_stream.next().await { + if let Ok(StreamEvent::Finish { response, .. }) = event { + last_response = Some(*response); + break; + } + } + last_response.ok_or_else(|| SdkError::Network { + message: "Stream ended without a finish event".into(), + }) + } } // --- Request types (Responses API format) --- @@ -111,6 +137,7 @@ struct ApiRequest { stop: Option>, #[serde(skip_serializing_if = "Option::is_none")] metadata: Option>, + store: bool, #[serde(skip_serializing_if = "std::ops::Not::not")] stream: bool, } @@ -348,7 +375,10 @@ fn translate_response_format(format: &ResponseFormat) -> Option ApiRequest { +/// +/// When `codex_mode` is true, unsupported fields (`temperature`, `max_output_tokens`, `top_p`) +/// are omitted and empty instructions are sent as `""` (required by the Codex endpoint). +fn build_api_request(request: &Request, stream: bool, codex_mode: bool) -> ApiRequest { let (instructions, input) = translate_input(&request.messages); let api_tools = request.tools.as_ref().map(|t| translate_tools(t)); let tool_choice = request.tool_choice.as_ref().map(translate_tool_choice); @@ -361,26 +391,37 @@ fn build_api_request(request: &Request, stream: bool) -> ApiRequest { .as_ref() .and_then(translate_response_format); + let instructions = if codex_mode { + Some(instructions.unwrap_or_default()) + } else { + instructions + }; + ApiRequest { model: request.model.clone(), input, instructions, - temperature: request.temperature, - max_output_tokens: request.max_tokens, - top_p: request.top_p, + temperature: if codex_mode { + None + } else { + request.temperature + }, + max_output_tokens: if codex_mode { None } else { request.max_tokens }, + top_p: if codex_mode { None } else { request.top_p }, tools: api_tools, tool_choice, reasoning, text, stop: request.stop_sequences.clone(), metadata: request.metadata.clone(), + store: false, stream, } } /// Serialize an `ApiRequest` to JSON and merge any `provider_options.openai` keys into it. -fn build_request_body(request: &Request, stream: bool) -> serde_json::Value { - let api_request = build_api_request(request, stream); +fn build_request_body(request: &Request, stream: bool, codex_mode: bool) -> serde_json::Value { + let api_request = build_api_request(request, stream, codex_mode); let mut body = serde_json::to_value(&api_request).unwrap_or_else(|_| serde_json::json!({})); if let Some(openai_opts) = request @@ -883,10 +924,15 @@ impl ProviderAdapter for Adapter { } async fn complete(&self, request: &Request) -> Result { + // Codex endpoint requires streaming; collect the stream into a response. + if self.codex_mode { + return self.complete_via_stream(request).await; + } + if let Some(tc) = &request.tool_choice { crate::provider::validate_tool_choice(self, tc)?; } - let request_body = build_request_body(request, false); + let request_body = build_request_body(request, false, false); let url = format!("{}/responses", self.http.base_url); let mut req = self.build_request(&url).json(&request_body); @@ -942,7 +988,7 @@ impl ProviderAdapter for Adapter { if let Some(tc) = &request.tool_choice { crate::provider::validate_tool_choice(self, tc)?; } - let request_body = build_request_body(request, true); + let request_body = build_request_body(request, true, self.codex_mode); let url = format!("{}/responses", self.http.base_url); let http_resp = self @@ -1040,7 +1086,7 @@ mod tests { let mut request = minimal_request(); request.metadata = Some(metadata); - let body = build_request_body(&request, false); + let body = build_request_body(&request, false, false); let meta = body.get("metadata").expect("metadata should be present"); assert_eq!(meta["user_id"], "u123"); assert_eq!(meta["session"], "s456"); @@ -1049,7 +1095,7 @@ mod tests { #[test] fn build_request_body_omits_metadata_when_none() { let request = minimal_request(); - let body = build_request_body(&request, false); + let body = build_request_body(&request, false, false); assert!(body.get("metadata").is_none()); } @@ -1063,7 +1109,7 @@ mod tests { } })); - let body = build_request_body(&request, false); + let body = build_request_body(&request, false, false); assert_eq!(body["store"], true); assert_eq!(body["previous_response_id"], "resp_abc123"); } @@ -1078,7 +1124,7 @@ mod tests { } })); - let body = build_request_body(&request, false); + let body = build_request_body(&request, false, false); // provider_options should override the base field assert_eq!(body["temperature"], 0.9); } @@ -1092,7 +1138,7 @@ mod tests { } })); - let body = build_request_body(&request, false); + let body = build_request_body(&request, false, false); // anthropic options should not leak into the OpenAI request assert!(body.get("thinking").is_none()); } @@ -1100,7 +1146,7 @@ mod tests { #[test] fn build_request_body_no_provider_options() { let request = minimal_request(); - let body = build_request_body(&request, false); + let body = build_request_body(&request, false, false); assert_eq!(body["model"], "gpt-4o"); // stream field is omitted when false (skip_serializing_if) assert!(body.get("stream").is_none()); @@ -1109,7 +1155,7 @@ mod tests { #[test] fn build_request_body_stream_flag() { let request = minimal_request(); - let body = build_request_body(&request, true); + let body = build_request_body(&request, true, false); assert!(body["stream"].as_bool().unwrap_or(false)); } @@ -1126,7 +1172,7 @@ mod tests { } })); - let body = build_request_body(&request, false); + let body = build_request_body(&request, false, false); assert_eq!(body["metadata"]["trace_id"], "t789"); assert_eq!(body["store"], true); } @@ -1509,7 +1555,7 @@ mod tests { let mut request = minimal_request(); request.stop_sequences = Some(vec!["END".to_string(), "STOP".to_string()]); - let body = build_request_body(&request, false); + let body = build_request_body(&request, false, false); let stop = body.get("stop").expect("stop should be present"); let arr = stop.as_array().expect("stop should be an array"); assert_eq!(arr.len(), 2); @@ -1520,7 +1566,7 @@ mod tests { #[test] fn build_request_body_omits_stop_when_none() { let request = minimal_request(); - let body = build_request_body(&request, false); + let body = build_request_body(&request, false, false); assert!(body.get("stop").is_none()); } diff --git a/lib/crates/fabro-openai-oauth/src/lib.rs b/lib/crates/fabro-openai-oauth/src/lib.rs index 0ed1eccad..82a9b0a38 100644 --- a/lib/crates/fabro-openai-oauth/src/lib.rs +++ b/lib/crates/fabro-openai-oauth/src/lib.rs @@ -77,10 +77,7 @@ pub fn build_authorize_url( ("response_type", "code"), ("client_id", client_id), ("redirect_uri", redirect_uri), - ( - "scope", - "openid profile email offline_access api.responses.write", - ), + ("scope", "openid profile email offline_access"), ("code_challenge", &pkce.challenge), ("code_challenge_method", "S256"), ("state", state),