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) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-17 00:47:47 -04:00
parent 0f565b4a1b
commit 459a9c221a
7 changed files with 105 additions and 31 deletions

View file

@ -1 +1 @@
610aff744609268ae879a58acfe6c9a6bb0572e0
e09a6de72fe7c69e8868abf5412c088bde05bece

View file

@ -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 <KEY=VALUE>` | 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

View file

@ -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]

View file

@ -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") {

View file

@ -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

View file

@ -21,6 +21,8 @@ pub struct Adapter {
pub(crate) http: super::http_api::HttpApi,
org_id: Option<String>,
project_id: Option<String>,
/// 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<String>) -> 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<Response, SdkError> {
use futures::StreamExt;
let mut event_stream = self.stream(request).await?;
let mut last_response: Option<Response> = 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<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
metadata: Option<std::collections::HashMap<String, String>>,
store: bool,
#[serde(skip_serializing_if = "std::ops::Not::not")]
stream: bool,
}
@ -348,7 +375,10 @@ fn translate_response_format(format: &ResponseFormat) -> Option<serde_json::Valu
}
/// Build an `ApiRequest` from a unified `Request`.
fn build_api_request(request: &Request, stream: bool) -> 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<Response, SdkError> {
// 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());
}

View file

@ -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),