mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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:
parent
0f565b4a1b
commit
459a9c221a
7 changed files with 105 additions and 31 deletions
|
|
@ -1 +1 @@
|
|||
610aff744609268ae879a58acfe6c9a6bb0572e0
|
||||
e09a6de72fe7c69e8868abf5412c088bde05bece
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue