1use serde::{Deserialize, Serialize};
2use tokio::sync::broadcast;
3use tokio_util::sync::CancellationToken;
4
5use crate::error::RuntimeError;
6use crate::event::{NodeEvent, Observable, TurnId};
7use crate::message::{ImageData, Message, MessageOrigin, MessagePart, MessageRole};
8use crate::provider::{
9 AssistantMessage, CallTiming, DEFAULT_STREAM_BUFFER, LlmRequest, Provider, StopReason,
10 TokenUsage, estimate_tokens,
11};
12use crate::tool::BoxFut;
13use anyhow::Context;
14
15const CODEX_BASE: &str = "https://chatgpt.com/backend-api/codex";
16
17pub struct CodexProvider {
19 name: String,
20 access_token: String,
21 account_id: String,
22 client: reqwest::Client,
23}
24
25impl CodexProvider {
26 pub fn new(
27 name: impl Into<String>,
28 access_token: impl Into<String>,
29 account_id: impl Into<String>,
30 ) -> Self {
31 Self {
32 name: name.into(),
33 access_token: access_token.into(),
34 account_id: account_id.into(),
35 client: reqwest::Client::new(),
36 }
37 }
38
39 fn build_body(&self, req: &LlmRequest) -> ResponsesRequest {
40 let model = req
41 .model
42 .split_once('/')
43 .map(|(_, slug)| slug)
44 .or_else(|| req.model.split_once(':').map(|(_, slug)| slug))
45 .unwrap_or(&req.model)
46 .to_string();
47
48 let input = build_input_items(req);
49 let tools = build_tools(&req.tools);
50
51 ResponsesRequest {
52 model,
53 input,
54 instructions: req.system.clone(),
55 tools,
56 stream: true,
57 store: false,
58 reasoning: Some(ReasoningConfig {
59 effort: Some("medium".into()),
60 summary: "auto".into(),
61 }),
62 text: Some(TextConfig {
63 verbosity: "medium".into(),
64 }),
65 include: Some(vec!["reasoning.encrypted_content".into()]),
66 }
67 }
68
69 fn build_request(&self, req: &LlmRequest) -> reqwest::RequestBuilder {
70 let body = self.build_body(req);
71 self.client
72 .post(format!("{CODEX_BASE}/responses"))
73 .bearer_auth(&self.access_token)
74 .header("chatgpt-account-id", &self.account_id)
75 .header("originator", "codex_cli_rs")
76 .header("OpenAI-Beta", "responses=experimental")
77 .header("accept", "text/event-stream")
78 .json(&body)
79 }
80}
81
82fn build_input_items(req: &LlmRequest) -> Vec<InputItem> {
83 let mut tool_names: std::collections::HashMap<String, String> =
84 std::collections::HashMap::new();
85 for m in &req.messages {
86 if m.role == MessageRole::Assistant {
87 for p in &m.parts {
88 if let MessagePart::ToolUse { id, name, .. } = p {
89 tool_names.insert(id.clone(), crate::tool_naming::to_wire(name));
91 }
92 }
93 }
94 }
95
96 let mut items: Vec<InputItem> = Vec::new();
97
98 for m in &req.messages {
99 match m.role {
100 MessageRole::User => {
101 let content = build_user_content(&m.parts);
102 items.push(InputItem {
103 role: Some("user".into()),
104 content: Some(content),
105 item_type: Some("message".into()),
106 call_id: None,
107 name: None,
108 arguments: None,
109 output: None,
110 });
111 }
112 MessageRole::Assistant => {
113 let (text, tool_calls) = split_assistant_parts(&m.parts);
114 if let Some(t) = text {
115 items.push(InputItem {
116 role: Some("assistant".into()),
117 content: Some(t),
118 item_type: Some("message".into()),
119 call_id: None,
120 name: None,
121 arguments: None,
122 output: None,
123 });
124 }
125 for tc in tool_calls {
126 items.push(InputItem {
127 role: None,
128 content: None,
129 item_type: Some("function_call".into()),
130 call_id: Some(tc.id),
131 name: Some(tc.name),
132 arguments: Some(tc.arguments),
133 output: None,
134 });
135 }
136 }
137 MessageRole::Tool => {
138 for p in &m.parts {
139 if let MessagePart::ToolResult {
140 tool_use_id,
141 content,
142 ..
143 } = p
144 {
145 let name = tool_names.get(tool_use_id).cloned();
146 items.push(InputItem {
147 role: None,
148 content: None,
149 item_type: Some("function_call_output".into()),
150 call_id: Some(tool_use_id.clone()),
151 name,
152 arguments: None,
153 output: Some(content.clone()),
154 });
155 }
156 }
157 }
158 MessageRole::System => {}
159 }
160 }
161
162 items
163}
164
165fn build_user_content(parts: &[MessagePart]) -> String {
166 let mut parts_out: Vec<serde_json::Value> = Vec::new();
167 for p in parts {
168 match p {
169 MessagePart::Text { text } => {
170 parts_out.push(serde_json::json!({"type": "input_text", "text": text}));
171 }
172 MessagePart::Image { source } => {
173 let data = match &source.data {
174 ImageData::Base64 { data } => data.clone(),
175 ImageData::Path { path } => {
176 let bytes = std::fs::read(path).unwrap_or_default();
177 use base64::Engine;
178 base64::engine::general_purpose::STANDARD.encode(&bytes)
179 }
180 };
181 parts_out.push(serde_json::json!({
182 "type": "input_image",
183 "image_url": format!("data:{};base64,{}", source.media_type, data)
184 }));
185 }
186 MessagePart::CompactSummary { summary, .. } => {
187 parts_out.push(serde_json::json!({"type": "input_text", "text": summary}));
188 }
189 _ => {}
190 }
191 }
192 if parts_out.len() == 1
193 && parts_out[0].get("type").and_then(|v| v.as_str()) == Some("input_text")
194 {
195 parts_out[0]["text"].as_str().unwrap_or("").to_string()
196 } else {
197 serde_json::to_string(&parts_out).unwrap_or_default()
198 }
199}
200
201struct AssistantSplit {
202 id: String,
203 name: String,
204 arguments: String,
205}
206
207fn split_assistant_parts(parts: &[MessagePart]) -> (Option<String>, Vec<AssistantSplit>) {
208 let mut text = String::new();
209 let mut tools: Vec<AssistantSplit> = Vec::new();
210 for p in parts {
211 match p {
212 MessagePart::Text { text: t } => text.push_str(t),
213 MessagePart::ToolUse { id, name, input } => tools.push(AssistantSplit {
214 id: id.clone(),
215 name: crate::tool_naming::to_wire(name),
217 arguments: serde_json::to_string(input).unwrap_or_default(),
218 }),
219 _ => {}
220 }
221 }
222 let text_out = if text.is_empty() { None } else { Some(text) };
223 (text_out, tools)
224}
225
226fn build_tools(tools: &[crate::tool::ToolSpec]) -> Vec<ResponsesTool> {
227 tools
228 .iter()
229 .map(|t| ResponsesTool {
230 r#type: "function".into(),
231 name: crate::tool_naming::to_wire(&t.name),
234 description: t.description.clone(),
235 parameters: t.input_schema.clone(),
236 })
237 .collect()
238}
239
240impl Provider for CodexProvider {
241 fn name(&self) -> &str {
242 &self.name
243 }
244
245 fn call<'a>(&'a self, req: LlmRequest) -> BoxFut<'a, Result<AssistantMessage, RuntimeError>> {
246 let observable = self.call_streaming(req);
249 Box::pin(async move {
250 let _events = observable.events;
252 observable.output.await
253 })
254 }
255
256 fn call_streaming(&self, req: LlmRequest) -> Observable<AssistantMessage> {
257 let request = self.build_request(&req);
258 let turn_id = turn_id_from_req(&req);
259 let streaming_tools = req.tools.clone();
260 let (tx, events) = broadcast::channel(DEFAULT_STREAM_BUFFER);
261 let cancel = CancellationToken::new();
262 let cancel_for_task = cancel.clone();
263
264 let output: BoxFut<'static, Result<AssistantMessage, RuntimeError>> =
265 Box::pin(async move {
266 use eventsource_stream::Eventsource;
267 use futures::StreamExt;
268
269 let resp = tokio::select! {
270 biased;
271 _ = cancel_for_task.cancelled() => {
272 return Err(RuntimeError::Cancelled("codex cancelled before send".into()));
273 }
274 r = request.send() => r.map_err(net_err)?,
275 };
276 let status = resp.status();
277 if !status.is_success() {
278 let body_text = resp.text().await.unwrap_or_default();
279 return Err(RuntimeError::ToolFailed(format!(
280 "codex http {status}: {body_text}"
281 )));
282 }
283
284 let mut stream = resp.bytes_stream().eventsource();
285
286 let mut acc_text = String::new();
287 let mut acc_thinking = String::new();
288 let mut cumulative = 0u64;
289 let mut final_usage: Option<ResponsesUsage> = None;
290 let mut resp_model: Option<String> = None;
291 let mut resp_id: Option<String> = None;
292 let mut stop_reason = StopReason::End;
293
294 let mut partial_tool_calls: Vec<PartialToolCall> = Vec::new();
295
296 while let Some(event) = tokio::select! {
297 biased;
298 _ = cancel_for_task.cancelled() => None,
299 next = stream.next() => next,
300 } {
301 let event =
302 event.map_err(|e| RuntimeError::ToolFailed(format!("codex sse: {e}")))?;
303 if event.data.is_empty() || event.data == "[DONE]" {
304 continue;
305 }
306 let parsed: serde_json::Value = match serde_json::from_str(&event.data) {
307 Ok(v) => v,
308 Err(_) => continue,
309 };
310
311 let ev_type = parsed["type"].as_str().unwrap_or("");
312
313 match ev_type {
314 "response.output_text.delta" => {
315 if let Some(delta) = parsed["delta"].as_str() {
316 acc_text.push_str(delta);
317 cumulative += estimate_tokens(delta);
318 let _ = tx.send(NodeEvent::LlmChunk {
319 text: delta.to_string(),
320 cumulative_tokens: cumulative,
321 });
322 }
323 }
324
325 "response.reasoning_text.delta" => {
326 if let Some(delta) = parsed["delta"].as_str() {
327 acc_thinking.push_str(delta);
328 let _ = tx.send(NodeEvent::ThinkingChunk {
329 text: delta.to_string(),
330 });
331 }
332 }
333
334 "response.output_item.added" => {
335 if let Some(item) = parsed.get("item")
336 && item["type"].as_str() == Some("function_call")
337 {
338 let idx = parsed["output_index"].as_u64().unwrap_or(0) as usize;
339 while partial_tool_calls.len() <= idx {
340 partial_tool_calls.push(PartialToolCall::default());
341 }
342 let slot = &mut partial_tool_calls[idx];
343 slot.id = item["call_id"].as_str().unwrap_or("").to_string();
344 slot.name = item["name"].as_str().unwrap_or("").to_string();
346 }
347 }
348
349 "response.function_call_arguments.delta" => {
350 let idx = parsed["output_index"].as_u64().unwrap_or(0) as usize;
351 while partial_tool_calls.len() <= idx {
352 partial_tool_calls.push(PartialToolCall::default());
353 }
354 if let Some(delta) = parsed["delta"].as_str() {
355 partial_tool_calls[idx].arguments.push_str(delta);
356 }
357 }
358
359 "response.completed" => {
360 if let Some(r) = parsed.get("response") {
361 resp_model = r["model"].as_str().map(|s| s.to_string());
362 resp_id = r["id"].as_str().map(|s| s.to_string());
363 if let Some(u) = r.get("usage") {
364 final_usage =
365 serde_json::from_value::<ResponsesUsage>(u.clone()).ok();
366 }
367 if r["status"].as_str() == Some("cancelled") {
368 stop_reason = StopReason::Cancelled;
369 }
370 }
371 }
372
373 "error" => {
374 let msg = parsed["message"].as_str().unwrap_or("unknown codex error");
375 return Err(RuntimeError::ToolFailed(msg.to_string()));
376 }
377
378 _ => {}
379 }
380 }
381
382 if cancel_for_task.is_cancelled() {
383 let _ = tx.send(NodeEvent::LlmDone {
384 total_tokens: cumulative,
385 });
386 return Err(RuntimeError::Cancelled("codex cancelled mid-stream".into()));
387 }
388
389 let total_output = final_usage
390 .as_ref()
391 .and_then(|u| u.output_tokens)
392 .unwrap_or(cumulative);
393 let _ = tx.send(NodeEvent::LlmDone {
394 total_tokens: total_output,
395 });
396
397 let mut parts: Vec<MessagePart> = Vec::new();
398 if !acc_thinking.is_empty() {
399 parts.push(MessagePart::Thinking {
400 thinking: acc_thinking,
401 signature: None,
402 });
403 }
404 if !acc_text.is_empty() {
405 parts.push(MessagePart::Text { text: acc_text });
406 }
407 for tc in partial_tool_calls {
408 if tc.name.is_empty() {
409 continue;
410 }
411 let input: serde_json::Value = if tc.arguments.is_empty() {
412 serde_json::Value::Object(Default::default())
413 } else {
414 serde_json::from_str(&tc.arguments).unwrap_or(serde_json::Value::Null)
415 };
416 parts.push(MessagePart::ToolUse {
417 id: tc.id,
418 name: crate::tool_naming::from_wire(&tc.name, &streaming_tools),
419 input,
420 });
421 }
422
423 let token_usage = final_usage.map(|u| TokenUsage {
424 input: u.input_tokens.unwrap_or(0),
425 cached_input: u
426 .input_tokens_details
427 .as_ref()
428 .and_then(|d| d.cached_tokens)
429 .unwrap_or(0),
430 output: u.output_tokens.unwrap_or(0),
431 cache_write: u
432 .input_tokens_details
433 .as_ref()
434 .and_then(|d| d.cache_write_tokens)
435 .unwrap_or(0),
436 reasoning_tokens: u
437 .output_tokens_details
438 .as_ref()
439 .and_then(|d| d.reasoning_tokens)
440 .unwrap_or(0),
441 });
442
443 Ok(AssistantMessage {
444 message: Message {
445 role: MessageRole::Assistant,
446 parts,
447 turn_id,
448 origin: MessageOrigin::User,
449 },
450 stop_reason,
451 token_usage: token_usage.unwrap_or_default(),
452 timing: CallTiming::default(),
453 model: resp_model.unwrap_or_default(),
454 response_id: resp_id,
455 })
456 });
457 Observable {
458 output,
459 events,
460 cancel,
461 }
462 }
463
464 fn discover_models(
465 &self,
466 ) -> crate::tool::BoxFut<'static, Vec<crate::provider::DiscoveredModel>> {
467 let access_token = self.access_token.clone();
468 let account_id = self.account_id.clone();
469 Box::pin(async move {
470 let client = reqwest::Client::new();
471 let resp = match client
472 .get("https://chatgpt.com/backend-api/wham/models")
473 .query(&[("client_version", "0.0.0")])
474 .bearer_auth(&access_token)
475 .header("ChatGPT-Account-Id", &account_id)
476 .send()
477 .await
478 {
479 Ok(r) => r,
480 Err(e) => {
481 crate::notify!(
482 warn,
483 location = Inline,
484 stack = dedupe("codex.models.fetch_failed", 60_000),
485 "fetch codex models failed: {e:#}"
486 );
487 return vec![];
488 }
489 };
490 let Ok(body) = resp.json::<serde_json::Value>().await else {
491 return vec![];
492 };
493 let Some(list) = body["models"].as_array() else {
494 return vec![];
495 };
496 list.iter()
497 .filter_map(|m| {
498 let slug = format!("codex/{}", m["slug"].as_str()?);
499 if slug.is_empty() {
500 return None;
501 }
502 let context_budget = m["context_window"].as_u64();
503 let thinking = m["supported_reasoning_levels"]
504 .as_array()
505 .map(|a: &Vec<serde_json::Value>| !a.is_empty())
506 .unwrap_or(false);
507 Some(crate::provider::DiscoveredModel {
508 slug,
509 context_budget,
510 thinking,
511 })
512 })
513 .collect()
514 })
515 }
516
517 fn test_connection(&self) -> BoxFut<'_, Result<String, String>> {
518 let access_token = self.access_token.clone();
519 let account_id = self.account_id.clone();
520 let name = self.name.clone();
521 Box::pin(async move {
522 let client = reqwest::Client::builder()
523 .timeout(std::time::Duration::from_secs(15))
524 .build()
525 .map_err(|e| e.to_string())?;
526 let resp = client
527 .get("https://chatgpt.com/backend-api/wham/models")
528 .query(&[("client_version", "0.0.0")])
529 .bearer_auth(&access_token)
530 .header("ChatGPT-Account-Id", &account_id)
531 .send()
532 .await
533 .map_err(|e| format!("connection failed — {e}"))?;
534 let status = resp.status();
535 if status.is_success() {
536 Ok(format!("\"{name}\" responded OK"))
537 } else {
538 let body = resp.text().await.unwrap_or_default();
539 Err(format!(
540 "returned {status} — {}",
541 &body[..body.len().min(200)]
542 ))
543 }
544 })
545 }
546}
547
548const CODEX_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
549const CODEX_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
550const CODEX_AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize";
551const CODEX_REDIRECT_URI: &str = "http://localhost:1455/auth/callback";
552
553impl crate::oauth::OAuthProvider for CodexProvider {
554 fn authorize_url() -> (String, crate::oauth::Pkce, String) {
555 let pkce = crate::oauth::Pkce::generate();
556 let state = crate::oauth::generate_state();
557 let url = format!(
558 "{}?response_type=code&client_id={}&redirect_uri={}&code_challenge={}&code_challenge_method=S256&state={}&scope=openid+profile+email+offline_access",
559 CODEX_AUTHORIZE_URL, CODEX_CLIENT_ID, CODEX_REDIRECT_URI, pkce.challenge, state
560 );
561 (url, pkce, state)
562 }
563
564 fn exchange_code(
565 code: &str,
566 verifier: &str,
567 ) -> std::pin::Pin<
568 Box<dyn std::future::Future<Output = anyhow::Result<crate::oauth::TokenResult>> + Send>,
569 > {
570 let code = code.to_string();
571 let verifier = verifier.to_string();
572 Box::pin(async move {
573 let client = reqwest::Client::new();
574 let resp = client
575 .post(CODEX_TOKEN_URL)
576 .form(&[
577 ("grant_type", "authorization_code"),
578 ("code", &code),
579 ("redirect_uri", CODEX_REDIRECT_URI),
580 ("client_id", CODEX_CLIENT_ID),
581 ("code_verifier", &verifier),
582 ])
583 .send()
584 .await
585 .context("token exchange request")?;
586
587 let status = resp.status();
588 let body_text = resp.text().await.unwrap_or_default();
589 if !status.is_success() {
590 anyhow::bail!("token exchange failed (HTTP {status}): {body_text}");
591 }
592
593 #[derive(serde::Deserialize)]
594 struct R {
595 access_token: String,
596 refresh_token: Option<String>,
597 id_token: Option<String>,
598 }
599 let data: R = serde_json::from_str(&body_text).context("parse token response")?;
600
601 let expires_at = crate::oauth::parse_jwt_exp(&data.access_token)
602 .unwrap_or_else(|| chrono::Utc::now().timestamp() + 3600);
603 let account = data
604 .id_token
605 .as_deref()
606 .and_then(crate::oauth::extract_account_from_id_token);
607
608 Ok(crate::oauth::TokenResult {
609 access_token: data.access_token,
610 refresh_token: data.refresh_token,
611 expires_at,
612 account,
613 })
614 })
615 }
616
617 fn refresh_token(
618 token: &str,
619 ) -> std::pin::Pin<
620 Box<dyn std::future::Future<Output = anyhow::Result<crate::oauth::TokenResult>> + Send>,
621 > {
622 let token = token.to_string();
623 Box::pin(async move {
624 let client = reqwest::Client::new();
625 let resp = client
626 .post(CODEX_TOKEN_URL)
627 .form(&[
628 ("grant_type", "refresh_token"),
629 ("refresh_token", &token),
630 ("client_id", CODEX_CLIENT_ID),
631 ])
632 .send()
633 .await
634 .context("token refresh request")?;
635
636 let status = resp.status();
637 let body_text = resp.text().await.unwrap_or_default();
638 if !status.is_success() {
639 anyhow::bail!("token refresh failed (HTTP {status}): {body_text}");
640 }
641
642 #[derive(serde::Deserialize)]
643 struct R {
644 access_token: String,
645 refresh_token: Option<String>,
646 id_token: Option<String>,
647 }
648 let data: R = serde_json::from_str(&body_text).context("parse refresh response")?;
649
650 let expires_at = crate::oauth::parse_jwt_exp(&data.access_token)
651 .unwrap_or_else(|| chrono::Utc::now().timestamp() + 3600);
652 let account = data
653 .id_token
654 .as_deref()
655 .and_then(crate::oauth::extract_account_from_id_token);
656
657 Ok(crate::oauth::TokenResult {
658 access_token: data.access_token,
659 refresh_token: data.refresh_token,
660 expires_at,
661 account,
662 })
663 })
664 }
665
666 fn from_stored(stored: &crate::auth_store::StoredProvider) -> Self {
667 let account_id = stored.account.as_deref().unwrap_or("");
668 CodexProvider::new(&stored.name, &stored.access_token, account_id)
669 }
670}
671
672#[derive(Default)]
673struct PartialToolCall {
674 id: String,
675 name: String,
676 arguments: String,
677}
678
679fn turn_id_from_req(req: &LlmRequest) -> TurnId {
680 req.messages
681 .first()
682 .map(|m| m.turn_id.clone())
683 .unwrap_or_else(TurnId::now)
684}
685
686fn net_err(e: reqwest::Error) -> RuntimeError {
687 RuntimeError::ToolFailed(format!("codex net: {e}"))
688}
689
690#[derive(Serialize)]
691struct ResponsesRequest {
692 model: String,
693 input: Vec<InputItem>,
694 #[serde(skip_serializing_if = "Option::is_none")]
695 instructions: Option<String>,
696 #[serde(skip_serializing_if = "Vec::is_empty")]
697 tools: Vec<ResponsesTool>,
698 stream: bool,
699 store: bool,
700 #[serde(skip_serializing_if = "Option::is_none")]
701 reasoning: Option<ReasoningConfig>,
702 #[serde(skip_serializing_if = "Option::is_none")]
703 text: Option<TextConfig>,
704 #[serde(skip_serializing_if = "Option::is_none")]
705 include: Option<Vec<String>>,
706}
707
708#[derive(Serialize)]
709struct InputItem {
710 #[serde(skip_serializing_if = "Option::is_none")]
711 role: Option<String>,
712 #[serde(skip_serializing_if = "Option::is_none")]
713 content: Option<String>,
714 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
715 item_type: Option<String>,
716 #[serde(skip_serializing_if = "Option::is_none")]
717 call_id: Option<String>,
718 #[serde(skip_serializing_if = "Option::is_none")]
719 name: Option<String>,
720 #[serde(skip_serializing_if = "Option::is_none")]
721 arguments: Option<String>,
722 #[serde(skip_serializing_if = "Option::is_none")]
723 output: Option<String>,
724}
725
726#[derive(Serialize)]
727struct ResponsesTool {
728 #[serde(rename = "type")]
729 r#type: String,
730 name: String,
731 #[serde(skip_serializing_if = "Option::is_none")]
732 description: Option<String>,
733 parameters: serde_json::Value,
734}
735
736#[derive(Serialize)]
737struct ReasoningConfig {
738 #[serde(skip_serializing_if = "Option::is_none")]
739 effort: Option<String>,
740 summary: String,
741}
742
743#[derive(Serialize)]
744struct TextConfig {
745 verbosity: String,
746}
747
748#[derive(Deserialize, Default)]
749struct ResponsesUsage {
750 #[serde(default)]
751 input_tokens: Option<u64>,
752 #[serde(default)]
753 output_tokens: Option<u64>,
754 #[serde(default)]
755 input_tokens_details: Option<InputTokensDetails>,
756 #[serde(default)]
757 output_tokens_details: Option<OutputTokensDetails>,
758}
759
760#[derive(Deserialize, Default)]
761struct InputTokensDetails {
762 #[serde(default)]
763 cached_tokens: Option<u64>,
764 #[serde(default)]
765 cache_write_tokens: Option<u64>,
766}
767
768#[derive(Deserialize, Default)]
769struct OutputTokensDetails {
770 #[serde(default)]
771 reasoning_tokens: Option<u64>,
772}