feat: add codex sse and usage helpers

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-11 23:19:01 +08:00
co-authored by Codex
parent 1b1e447697
commit 225fbffb75
15 changed files with 305 additions and 12 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
use reqwest::Client;
pub fn build_reqwest_client(force_http11: bool) -> Result<Client, reqwest::Error> {
// 中文注释:Codex Desktop 指纹依赖 reqwest/rustls 组合,升级前必须重新验证 TLS 行为。
// Codex Desktop 指纹依赖 reqwest/rustls 组合,升级前必须重新验证 TLS 行为。
let builder = Client::builder()
.use_rustls_tls()
.no_proxy()
+1 -1
View File
@@ -18,7 +18,7 @@ pub fn build_codex_headers(
headers.insert("originator".to_string(), fp.originator.clone());
headers.insert("user-agent".to_string(), fp.user_agent());
headers.insert("x-client-request-id".to_string(), request_id.to_string());
// 中文注释:这些 Codex Desktop 私有头是上游识别链路的一部分,不能按普通 OpenAI API 精简。
// 这些 Codex Desktop 私有头是上游识别链路的一部分,不能按普通 OpenAI API 精简。
headers.insert(
"x-openai-internal-codex-residency".to_string(),
"global".to_string(),
+110
View File
@@ -1 +1,111 @@
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SseEvent {
pub event: Option<String>,
pub data: String,
pub id: Option<String>,
pub retry: Option<u64>,
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum SseError {
#[error("invalid SSE retry value: {0}")]
InvalidRetry(String),
}
#[derive(Debug, Default)]
struct EventBuilder {
event: Option<String>,
data: String,
has_data: bool,
id: Option<String>,
retry: Option<u64>,
}
impl EventBuilder {
fn push_data(&mut self, value: &str) {
if self.has_data {
self.data.push('\n');
}
self.data.push_str(value);
self.has_data = true;
}
fn finish(&mut self) -> Option<SseEvent> {
if !self.has_data {
self.event = None;
self.id = None;
self.retry = None;
return None;
}
self.has_data = false;
Some(SseEvent {
event: self.event.take(),
data: std::mem::take(&mut self.data),
id: self.id.take(),
retry: self.retry.take(),
})
}
}
pub fn parse_sse_events(input: &str) -> Result<Vec<SseEvent>, SseError> {
let mut events = Vec::new();
let mut builder = EventBuilder::default();
for raw_line in input.lines() {
let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
if line.is_empty() {
if let Some(event) = builder.finish() {
events.push(event);
}
continue;
}
if line.starts_with(':') {
continue;
}
let (field, value) = split_sse_field(line);
match field {
"event" => builder.event = Some(value.to_string()),
"data" => builder.push_data(value),
"id" if !value.contains('\0') => builder.id = Some(value.to_string()),
"retry" => {
builder.retry = Some(
value
.parse::<u64>()
.map_err(|_| SseError::InvalidRetry(value.to_string()))?,
);
}
_ => {}
}
}
if let Some(event) = builder.finish() {
events.push(event);
}
Ok(events)
}
pub fn encode_sse_event(event: &str, data: &str) -> String {
let mut frame = String::new();
if !event.is_empty() {
frame.push_str("event: ");
frame.push_str(event);
frame.push('\n');
}
for line in data.split('\n') {
frame.push_str("data: ");
frame.push_str(line);
frame.push('\n');
}
frame.push('\n');
frame
}
fn split_sse_field(line: &str) -> (&str, &str) {
let Some((field, value)) = line.split_once(':') else {
return (line, "");
};
(field, value.strip_prefix(' ').unwrap_or(value))
}
+80
View File
@@ -1 +1,81 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TokenUsage {
pub input_tokens: u64,
pub output_tokens: u64,
pub cached_tokens: u64,
pub total_tokens: u64,
}
impl TokenUsage {
pub fn new(input_tokens: u64, output_tokens: u64, cached_tokens: u64) -> Self {
Self {
input_tokens,
output_tokens,
cached_tokens,
total_tokens: input_tokens + output_tokens,
}
}
pub fn merged(self, other: Self) -> Self {
Self {
input_tokens: self.input_tokens + other.input_tokens,
output_tokens: self.output_tokens + other.output_tokens,
cached_tokens: self.cached_tokens + other.cached_tokens,
total_tokens: self.total_tokens + other.total_tokens,
}
}
}
pub fn extract_usage(body: &Value) -> Option<TokenUsage> {
let usage = body.get("usage").unwrap_or(body);
if !usage.is_object() {
return None;
}
let input_tokens = number_field(usage, "input_tokens")
.or_else(|| number_field(usage, "prompt_tokens"))
.unwrap_or_default();
let output_tokens = number_field(usage, "output_tokens")
.or_else(|| number_field(usage, "completion_tokens"))
.unwrap_or_default();
let cached_tokens = nested_number_field(usage, &["input_tokens_details", "cached_tokens"])
.or_else(|| nested_number_field(usage, &["prompt_tokens_details", "cached_tokens"]))
.or_else(|| number_field(usage, "cached_tokens"))
.unwrap_or_default();
let total_tokens = number_field(usage, "total_tokens").unwrap_or(input_tokens + output_tokens);
let has_usage = [
"input_tokens",
"output_tokens",
"prompt_tokens",
"completion_tokens",
"cached_tokens",
"total_tokens",
]
.iter()
.any(|field| usage.get(*field).is_some())
|| usage.get("input_tokens_details").is_some()
|| usage.get("prompt_tokens_details").is_some();
has_usage.then_some(TokenUsage {
input_tokens,
output_tokens,
cached_tokens,
total_tokens,
})
}
fn number_field(value: &Value, field: &str) -> Option<u64> {
value.get(field)?.as_u64()
}
fn nested_number_field(value: &Value, path: &[&str]) -> Option<u64> {
let mut current = value;
for segment in path {
current = current.get(*segment)?;
}
current.as_u64()
}