Backend Client统一访问
本文承接 RateLimit与配额状态、HTTP Client路由与中间件 和 ModelClient结构。前文分别解释了限额快照如何进入 Session,以及底层 HTTP/WebSocket 如何选择路线;本文向上追踪一个业务 backend 请求如何被构造、认证、发送、解码并交给调用方。
先纠正一个容易从旧资料得到的印象:当前 codex-backend-client 不是多模型 Provider 抽象,也不负责 Responses 流事件。它是一个面向 Codex backend 业务端点的客户端,统一访问账户、任务、配置、工作区消息、用户设置和限额状态等接口。模型 Provider 决定“向哪个模型发送推理请求”;Backend Client 决定“如何访问 backend 的业务资源”。
本文固定回答一个问题:同一组业务方法为什么可以同时访问 /api/codex/... 和 /wham/...,而认证、代理、缓存控制、HTTP 错误和 OpenAPI 类型不会散落在每个方法里?读者读完后应能从 Client::get_user_settings 或 Client::list_tasks 复述到最终 JSON 类型,并能判断 401、非 2xx、JSON 解码失败和字段缺失分别在哪一层暴露。
1. 访问链
Backend Client 把“业务方法”和“传输细节”分开:Client 保存 base URL、路径风格、认证 provider 和 route-aware pool;每个公开方法只负责选择 endpoint、Header 和目标返回类型。
这条链上有两个不同的“类型边界”。codex-backend-openapi-models 提供服务 schema 对应的输入输出类型;codex-backend-client 又把其中一部分转换成 codex_protocol 的 RateLimitSnapshot 等运行时类型,或用手写结构处理 Cloud Tasks 的不稳定响应。不能把所有返回值都看成直接透传的 OpenAPI 类型。
2. Client持有状态
Client 是可 clone 的配置对象,不是每次请求都重建的短生命周期 builder。构造时会规范化 ChatGPT 主机名:没有 /backend-api 的 chatgpt.com 或 chat.openai.com 会补上该前缀;随后依据 base URL 选择路径风格,并创建关闭普通 URL/响应头诊断的 cookie-aware route pool。
源码位置:codex-rs/backend-client/src/client.rs :: Client、Client::new、PathStyle::from_base_url
#[derive(Clone)]
pub struct Client {
base_url: String,
http: RouteAwareClientPool,
auth_provider: SharedAuthProvider,
user_agent: Option<HeaderValue>,
chatgpt_account_id: Option<String>,
chatgpt_account_is_fedramp: bool,
path_style: PathStyle,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PathStyle {
/// /api/codex/…
CodexApi,
/// /wham/…
ChatGptApi,
}
impl PathStyle {
pub fn from_base_url(base_url: &str) -> Self {
if base_url.contains("/backend-api") {
PathStyle::ChatGptApi
} else {
PathStyle::CodexApi
}
}
}
impl Client {
pub fn new(base_url: impl Into<String>, http_client_factory: HttpClientFactory) -> Self {
let mut base_url = base_url.into();
while base_url.ends_with('/') {
base_url.pop();
}
if (base_url.starts_with("https://chatgpt.com")
|| base_url.starts_with("https://chat.openai.com"))
&& !base_url.contains("/backend-api")
{
base_url = format!("{base_url}/backend-api");
}
let http = RouteAwareClientPool::with_chatgpt_cloudflare_cookies_without_request_logging(
http_client_factory,
ClientRouteClass::Api,
);
let path_style = PathStyle::from_base_url(&base_url);
Self {
base_url,
http,
auth_provider: codex_model_provider::unauthenticated_auth_provider(),
user_agent: None,
chatgpt_account_id: None,
chatgpt_account_is_fedramp: false,
path_style,
}
}
}这里的路径风格是 Client 级状态,而不是每个方法临时猜测。一个 Client 实例只能稳定地对应一种 base URL 语义;如果调用方要访问另一种 backend,应创建另一个 Client,而不是在同一个请求中混拼 /api/codex 与 /wham。
关系图中的三项依赖有不同生命周期:PathStyle 是复制值,pool 和 auth provider 则随 Client clone 共享其底层能力。业务方法消费这些成员,但不会反向修改路径风格或认证实现。
3. 认证与Header
认证 Header 的 owner 是 Client::headers。它先选择显式或默认 User-Agent,再让 SharedAuthProvider 注入认证,最后补充 ChatGPT workspace 和 FedRAMP 路由 Header。业务方法只调用 self.headers(),不会自行复制这套顺序。
源码位置:codex-rs/backend-client/src/client.rs :: Client::from_auth、Client::headers
pub fn from_auth(
base_url: impl Into<String>,
auth: &CodexAuth,
http_client_factory: HttpClientFactory,
) -> Self {
Self::new(base_url, http_client_factory)
.with_user_agent(get_codex_user_agent())
.with_auth_provider(codex_model_provider::auth_provider_from_auth(auth))
}
fn headers(&self) -> HeaderMap {
let mut h = HeaderMap::new();
if let Some(ua) = &self.user_agent {
h.insert(USER_AGENT, ua.clone());
} else {
h.insert(USER_AGENT, HeaderValue::from_static("codex-cli"));
}
self.auth_provider.add_auth_headers(&mut h);
if let Some(acc) = &self.chatgpt_account_id
&& let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id")
&& let Ok(hv) = HeaderValue::from_str(acc)
{
h.insert(name, hv);
}
if self.chatgpt_account_is_fedramp
&& let Ok(name) = HeaderName::from_bytes(b"X-OpenAI-Fedramp")
{
h.insert(name, HeaderValue::from_static("true"));
}
h
}from_auth 不直接读取 token 字符串,而是把 CodexAuth 转成 SharedAuthProvider;这样 backend client 不需要知道 token 是外部 ChatGPT token、API key 还是其他 provider 实现。Header 生成的失败点也很具体:非法 account id 无法转成 HeaderValue 时会被跳过,认证 provider 自己决定是否能注入有效 Authorization。
4. 双路径分派
业务方法的差异主要集中在 URL 构造。以账户检查和任务列表为例,Codex API 风格使用 /api/codex,ChatGPT backend-api 风格使用 /wham;请求方法、Header 生成、route pool 和 JSON 解码保持一致。
源码位置:codex-rs/backend-client/src/client.rs :: get_accounts_check、list_tasks_url、list_tasks
pub async fn get_accounts_check(&self) -> Result<AccountsCheckResponse> {
let url = match self.path_style {
PathStyle::CodexApi => format!("{}/api/codex/accounts/check", self.base_url),
PathStyle::ChatGptApi => format!("{}/wham/accounts/check", self.base_url),
};
let req = self.request(Method::GET, &url).headers(self.headers());
let (body, ct) = self.exec_request(req, "GET", &url).await?;
self.decode_json(&url, &ct, &body)
}
fn list_tasks_url(
&self,
limit: Option<i32>,
task_filter: Option<&str>,
environment_id: Option<&str>,
cursor: Option<&str>,
) -> Result<String> {
let url = match self.path_style {
PathStyle::CodexApi => format!("{}/api/codex/tasks/list", self.base_url),
PathStyle::ChatGptApi => format!("{}/wham/tasks/list", self.base_url),
};
if limit.is_none() && task_filter.is_none() && environment_id.is_none() && cursor.is_none() {
return Ok(url);
}
let mut url = url::Url::parse(&url)?;
{
let mut query = url.query_pairs_mut();
if let Some(limit) = limit {
query.append_pair("limit", &limit.to_string());
}
if let Some(task_filter) = task_filter {
query.append_pair("task_filter", task_filter);
}
if let Some(cursor) = cursor {
query.append_pair("cursor", cursor);
}
if let Some(environment_id) = environment_id {
query.append_pair("environment_id", environment_id);
}
}
Ok(url.to_string())
}查询参数通过 Url::query_pairs_mut 编码,而不是字符串拼接。测试中的 mine / shared、env&one 和 next=page 会分别变成 URL 编码,避免业务筛选条件改变 query 结构。这个细节是 backend client 的输入边界,不应交给调用方预编码。
5. 请求与错误
所有普通请求先进入 RouteAwareRequestBuilder,再由 exec_request 统一读取 status、Content-Type 和 body。非 2xx 直接转成 anyhow 错误;需要调用方判断 HTTP 状态的配置和用户设置接口则使用 RequestError::UnexpectedStatus,保留 method、URL、status、content-type 和 body。
源码位置:codex-rs/backend-client/src/client.rs :: RequestError、exec_request、exec_request_detailed、decode_json
#[derive(Debug)]
pub enum RequestError {
UnexpectedStatus {
method: String,
url: String,
status: StatusCode,
content_type: String,
body: String,
},
Other(anyhow::Error),
}
impl RequestError {
pub fn status(&self) -> Option<StatusCode> {
match self {
Self::UnexpectedStatus { status, .. } => Some(*status),
Self::Other(_) => None,
}
}
pub fn is_unauthorized(&self) -> bool {
self.status() == Some(StatusCode::UNAUTHORIZED)
}
}
async fn exec_request(
&self,
req: RouteAwareRequestBuilder,
method: &str,
url: &str,
) -> Result<(String, String)> {
let res = req.send().await?;
let status = res.status();
let ct = res
.headers()
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let body = res.text().await.unwrap_or_default();
if !status.is_success() {
anyhow::bail!("{method} {url} failed: {status}; content-type={ct}; body={body}");
}
Ok((body, ct))
}
fn decode_json<T: DeserializeOwned>(&self, url: &str, ct: &str, body: &str) -> Result<T> {
match serde_json::from_str::<T>(body) {
Ok(v) => Ok(v),
Err(e) => {
anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}");
}
}
}exec_request 会读取完整 body,所以它适合 unary backend API,不是 Responses SSE 的流解析器。错误文本保留 body 是为了诊断服务端错误,但调用方仍应注意 body 可能含敏感信息;底层 pool 已关闭普通 URL/响应头诊断,业务错误是否展示由上层决定。
状态图表达的是调用链阶段,不是 Client 内部保存的可变状态。一次请求失败后,Client 本身仍可复用;是否对 401 刷新认证、对网络错误重试或向用户展示 body,仍由调用方决定。
6. 用户设置请求
用户设置接口展示了“业务语义如何落到 Header”:除了统一认证 Header,它额外发送 Cache-Control: no-cache, no-store,因为该响应承载当前账户的有效策略,不应依赖中间缓存。Codex API 和 ChatGPT backend-api 使用不同 path,但返回同一个手写响应结构。
源码位置:codex-rs/backend-client/src/client.rs :: get_user_settings、user_settings_url
pub async fn get_user_settings(
&self,
) -> std::result::Result<CodexUserSettingsResponse, RequestError> {
let url = self.user_settings_url();
let req = self
.request(Method::GET, &url)
.headers(self.headers())
.header(
CACHE_CONTROL,
HeaderValue::from_static("no-cache, no-store"),
);
let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?;
self.decode_json::<CodexUserSettingsResponse>(&url, &ct, &body)
.map_err(RequestError::from)
}
fn user_settings_url(&self) -> String {
match self.path_style {
PathStyle::CodexApi => format!("{}/api/codex/settings/user", self.base_url),
PathStyle::ChatGptApi => format!("{}/wham/settings/user", self.base_url),
}
}CodexUserSettingsResponse 的字段使用 #[serde(default)],因此旧 backend 缺少 commit_attribution_enabled 时,客户端得到 false 而不是解码失败。这是兼容性策略,不代表服务端一定支持该功能。
源码位置:codex-rs/backend-client/src/types.rs :: CodexUserSettingsResponse
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
pub struct CodexUserSettingsResponse {
#[serde(default)]
pub commit_attribution_enabled: bool,
}7. 限额转换
Backend 的限额 payload 与 Core 使用的 RateLimitSnapshot 不是同一个 schema。rate_limit_snapshots_from_payload 固定先创建 codex 主快照,再把 additional_rate_limits 展开为额外快照;credits 和 spend-control 只属于主快照,额外限制族保留窗口和 plan 信息但不复制 credits。
源码位置:codex-rs/backend-client/src/client.rs :: rate_limit_snapshots_from_payload、make_rate_limit_snapshot
fn rate_limit_snapshots_from_payload(
payload: RateLimitStatusPayload,
) -> Vec<RateLimitSnapshot> {
let plan_type = Some(Self::map_plan_type(payload.plan_type));
let rate_limit_reached_type = payload
.rate_limit_reached_type
.flatten()
.and_then(|details| Self::map_rate_limit_reached_type(details.kind));
let mut snapshots = vec![Self::make_rate_limit_snapshot(
Some("codex".to_string()),
/*limit_name*/ None,
payload.rate_limit.flatten().map(|details| *details),
payload.credits.flatten().map(|details| *details),
payload.spend_control.flatten().map(|details| *details),
plan_type,
rate_limit_reached_type,
)];
if let Some(additional) = payload.additional_rate_limits.flatten() {
snapshots.extend(additional.into_iter().map(|details| {
Self::make_rate_limit_snapshot(
Some(details.metered_feature),
Some(details.limit_name),
details.rate_limit.flatten().map(|rate_limit| *rate_limit),
/*credits*/ None,
/*spend_control*/ None,
plan_type,
/*rate_limit_reached_type*/ None,
)
}));
}
snapshots
}
fn make_rate_limit_snapshot(
limit_id: Option<String>,
limit_name: Option<String>,
rate_limit: Option<crate::types::RateLimitStatusDetails>,
credits: Option<crate::types::CreditStatusDetails>,
spend_control: Option<SpendControlStatusDetails>,
plan_type: Option<AccountPlanType>,
rate_limit_reached_type: Option<RateLimitReachedType>,
) -> RateLimitSnapshot {
let (primary, secondary) = match rate_limit {
Some(details) => (
Self::map_rate_limit_window(details.primary_window),
Self::map_rate_limit_window(details.secondary_window),
),
None => (None, None),
};
let spend_control_reached = spend_control.as_ref().map(|details| details.reached);
let individual_limit = spend_control
.and_then(|details| details.individual_limit.flatten())
.map(|details| Self::map_individual_limit(*details));
RateLimitSnapshot {
limit_id,
limit_name,
primary,
secondary,
credits: Self::map_credits(credits),
individual_limit,
spend_control_reached,
plan_type,
rate_limit_reached_type,
}
}这段映射解释了一个重要消费者边界:HTTP backend 返回的 plan_type、window seconds、credits 和 reached kind 必须先转换,Core 才能按协议层的 RateLimitSnapshot 消费。MDL015 讨论的是 header/SSE/WebSocket 快照进入 Session;本篇补上 backend endpoint 这条来源路径。
8. 任务详情模型
Cloud Tasks 的详情响应没有完全依赖生成的 OpenAPI 类型。源码注释明确说生成模型质量不足,因此 types.rs 手写 CodeTaskDetailsResponse、Turn、TurnItem 和 content fragment,并通过扩展 trait 向调用方提供 unified diff、assistant 文本、用户 prompt 和错误摘要。
源码位置:codex-rs/backend-client/src/types.rs :: CodeTaskDetailsResponse、CodeTaskDetailsResponseExt
#[derive(Clone, Debug, Deserialize)]
pub struct CodeTaskDetailsResponse {
#[serde(default)]
pub current_user_turn: Option<Turn>,
#[serde(default)]
pub current_assistant_turn: Option<Turn>,
#[serde(default)]
pub current_diff_task_turn: Option<Turn>,
}
pub trait CodeTaskDetailsResponseExt {
fn unified_diff(&self) -> Option<String>;
fn assistant_text_messages(&self) -> Vec<String>;
fn user_text_prompt(&self) -> Option<String>;
fn assistant_error_message(&self) -> Option<String>;
}
impl CodeTaskDetailsResponseExt for CodeTaskDetailsResponse {
fn unified_diff(&self) -> Option<String> {
[
self.current_diff_task_turn.as_ref(),
self.current_assistant_turn.as_ref(),
]
.into_iter()
.flatten()
.find_map(Turn::unified_diff)
}
fn assistant_text_messages(&self) -> Vec<String> {
let mut out = Vec::new();
for turn in [
self.current_diff_task_turn.as_ref(),
self.current_assistant_turn.as_ref(),
]
.into_iter()
.flatten()
{
out.extend(turn.message_texts());
}
out
}
fn user_text_prompt(&self) -> Option<String> {
self.current_user_turn.as_ref().and_then(Turn::user_prompt)
}
fn assistant_error_message(&self) -> Option<String> {
self.current_assistant_turn
.as_ref()
.and_then(Turn::error_summary)
}
}unified_diff 先看 diff task turn,再回退到 assistant turn;assistant_text_messages 同时读取 output message 和 assistant worklog;user_text_prompt 只从 user turn 的 message item 提取文本。它们不是通用 JSON flatten,而是针对任务详情的业务投影,调用方因此不必了解后端响应中的多个 turn 槽位。
9. Backend测试
9.1 请求契约
migrated_requests_preserve_query_auth_and_json_body 启动本地 HTTP listener,让 list_tasks 和 create_task 依次返回 JSON。测试断言 query 参数被编码、Authorization 被注入、POST body 保留 JSON,并检查创建响应中的 task.id 被提取为字符串。它证明请求构造和最小成功解码链,不证明真实 backend 的 schema 完整性。
源码位置:codex-rs/backend-client/src/client_request_tests.rs :: migrated_requests_preserve_query_auth_and_json_body
#[test]
fn list_tasks_url_omits_empty_query_and_encodes_all_parameters() {
let client = Client::new(
"https://example.test",
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
);
assert_eq!(
client
.list_tasks_url(
/*limit*/ Some(10),
/*task_filter*/ Some("mine / shared"),
/*environment_id*/ Some("env&one"),
/*cursor*/ Some("next=page"),
)
.unwrap(),
"https://example.test/api/codex/tasks/list?limit=10&task_filter=mine+%2F+shared&cursor=next%3Dpage&environment_id=env%26one"
);
}9.2 限额映射
usage_payload_maps_primary_and_additional_rate_limits 构造主窗口、次窗口、additional limit、credits、spend-control 和 reached kind,断言输出包含两个 snapshot,并确认 credits/individual limit 只位于主 codex snapshot。它证明字段映射和所有权,不证明 backend 会在每个响应中提供这些可选字段。
9.3 兼容与认证
user_settings_request_uses_expected_paths_and_revalidates_cached_responses 用 wiremock 同时挂载 Codex API 与 ChatGPT backend-api 两条 path,匹配 Cache-Control: no-cache, no-store,再断言两个响应的字段值分别解码。authenticated_user_settings_client_uses_active_workspace_headers 则断言外部 ChatGPT token 和 workspace id 进入 Header;它不证明 token 已被服务器接受。
9.4 任务详情 fixture
types.rs 使用 task_details_with_diff.json 与 task_details_with_error.json fixture,分别验证 diff 优先级、assistant 文本拼接、用户 prompt 和错误摘要。fixture 只覆盖仓库保存的两种响应形态,不证明未知 content fragment 或未来 schema 字段会被保留。
10. 实践验证
可运行以下测试,再沿源码搜索对应 owner:
cargo test -p codex-backend-client migrated_requests_preserve_query_auth_and_json_body
cargo test -p codex-backend-client usage_payload_maps_primary_and_additional_rate_limits
cargo test -p codex-backend-client user_settings_request_uses_expected_paths_and_revalidates_cached_responses
cargo test -p codex-backend-client unified_diff_falls_back_to_pr_output_diff故障定位时先判断阶段:URL 不对看 PathStyle 和对应 *_url;401 看 headers() 与 RequestError::is_unauthorized;非 2xx 看 exec_request/exec_request_detailed;JSON 失败看 decode_json;字段语义不对则继续追 rate_limit_snapshots_from_payload 或 CodeTaskDetailsResponseExt。不要在 backend-client 中寻找 SSE parser、WebSocket reconnect 或模型请求 retry。
11. 技术边界
这些代码和测试覆盖 Backend Client 的业务访问边界:双路径 endpoint、统一认证 Header、route-aware HTTP、错误分层、JSON 解码、限额协议映射和 Cloud Tasks 手写投影。它们没有覆盖服务端权限、真实账户套餐、OpenAPI generator 的完整 schema 或上层如何消费任务详情;这些需要分别回到 backend 服务、生成模型 crate 和具体调用方验证。
下一篇 ChatGPT与API-Key认证接入 将比较 CodexAuth、API key 与 provider auth 如何改变 endpoint、Header 和账户状态;本文只建立它们最终进入 backend client 的统一请求边界。
