feat(codex-oauth): fetch model list from ChatGPT backend on demand

- Add `get_codex_oauth_models` Tauri command reusing the managed OAuth
  access token to hit `chatgpt.com/backend-api/codex/models`; HTTP and
  multi-shape JSON parsing live in `services::codex_oauth_models` so the
  command stays thin.
- Unify the Claude form's "fetch models" button across normal / Copilot /
  Codex OAuth presets, drop the auto-load effect for Copilot in favor of
  explicit clicks, and guard against stale responses with a requestId ref.
- Add Vitest coverage for both Copilot and Codex OAuth paths asserting no
  request on mount and the correct account id on click; add Rust unit
  tests for the four model-list payload shapes.
This commit is contained in:
Jason
2026-05-14 15:03:31 +08:00
parent f93b935d5f
commit 73bc4eb65d
7 changed files with 557 additions and 47 deletions
+33 -1
View File
@@ -3,9 +3,10 @@
//! 提供 OpenAI ChatGPT Plus/Pro OAuth 认证相关的 Tauri 命令。
//!
//! 大部分认证命令通过通用 `auth_*` 命令(参见 `commands::auth`)暴露给前端,
//! 此处定义 State wrapper 以及 Codex OAuth 专属的订阅额度查询命令。
//! 此处定义 State wrapper 以及 Codex OAuth 专属的订阅额度和模型列表查询命令。
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
use crate::services::model_fetch::FetchedModel;
use crate::services::subscription::{query_codex_quota, CredentialStatus, SubscriptionQuota};
use std::sync::Arc;
use tauri::State;
@@ -56,3 +57,34 @@ pub async fn get_codex_oauth_quota(
)
.await)
}
/// 获取 Codex OAuth (ChatGPT Plus/Pro) 可用模型列表
///
/// ChatGPT Codex 反代使用 `chatgpt.com/backend-api/codex/*`,不是 OpenAI 兼容
/// `/v1/models`。这里复用托管 OAuth 账号的 access_token,直接读取 Codex 后端
/// 暴露的模型列表端点。
#[tauri::command(rename_all = "camelCase")]
pub async fn get_codex_oauth_models(
account_id: Option<String>,
state: State<'_, CodexOAuthState>,
) -> Result<Vec<FetchedModel>, String> {
let manager = state.0.read().await;
let resolved = match account_id
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty())
{
Some(id) => Some(id.to_string()),
None => manager.default_account_id().await,
};
let Some(id) = resolved else {
return Err("No ChatGPT account available".to_string());
};
let token = manager
.get_valid_token_for_account(&id)
.await
.map_err(|e| format!("Codex OAuth token unavailable: {e}"))?;
crate::services::codex_oauth_models::fetch_models_with_token(&token, &id).await
}
+1
View File
@@ -1105,6 +1105,7 @@ pub fn run() {
// subscription quota
commands::get_subscription_quota,
commands::get_codex_oauth_quota,
commands::get_codex_oauth_models,
commands::get_coding_plan_quota,
commands::get_balance,
// New MCP via config.json (SSOT)
@@ -0,0 +1,192 @@
//! Codex OAuth model list service.
//!
//! ChatGPT Codex exposes models through `chatgpt.com/backend-api/codex/models`,
//! which is not an OpenAI-compatible `/v1/models` endpoint.
use crate::services::model_fetch::FetchedModel;
use serde_json::Value;
use std::time::Duration;
const CODEX_OAUTH_MODELS_URL: &str = "https://chatgpt.com/backend-api/codex/models";
const CODEX_OAUTH_FETCH_TIMEOUT_SECS: u64 = 15;
const ERROR_BODY_MAX_CHARS: usize = 512;
const CODEX_OAUTH_CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
pub async fn fetch_models_with_token(
token: &str,
account_id: &str,
) -> Result<Vec<FetchedModel>, String> {
let client = crate::proxy::http_client::get();
let response = client
.get(CODEX_OAUTH_MODELS_URL)
.query(&[("client_version", CODEX_OAUTH_CLIENT_VERSION)])
.header("Authorization", format!("Bearer {token}"))
.header("originator", "cc-switch")
.header("chatgpt-account-id", account_id)
.timeout(Duration::from_secs(CODEX_OAUTH_FETCH_TIMEOUT_SECS))
.send()
.await
.map_err(|e| format!("Request failed: {e}"))?;
let status = response.status();
if !status.is_success() {
let body = truncate_body(response.text().await.unwrap_or_default());
return Err(format!("HTTP {status}: {body}"));
}
let value: Value = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {e}"))?;
Ok(parse_models(value))
}
fn parse_models(value: Value) -> Vec<FetchedModel> {
let entries = value
.get("data")
.and_then(Value::as_array)
.or_else(|| value.get("models").and_then(Value::as_array))
.or_else(|| value.get("items").and_then(Value::as_array))
.or_else(|| value.as_array());
let mut models = Vec::new();
if let Some(entries) = entries {
for entry in entries {
push_model_entry(&mut models, entry, None);
}
}
if let Some(model_map) = value.get("models").and_then(Value::as_object) {
for (key, entry) in model_map {
push_model_entry(&mut models, entry, Some(key));
}
}
models.sort_by(|a, b| a.id.cmp(&b.id));
models.dedup_by(|a, b| a.id == b.id);
models
}
fn push_model_entry(models: &mut Vec<FetchedModel>, entry: &Value, fallback_id: Option<&str>) {
if let Some(id) = entry.as_str().map(str::trim).filter(|id| !id.is_empty()) {
models.push(FetchedModel {
id: id.to_string(),
owned_by: Some("Codex".to_string()),
});
return;
}
let Some(obj) = entry.as_object() else {
if let Some(id) = fallback_id.map(str::trim).filter(|id| !id.is_empty()) {
models.push(FetchedModel {
id: id.to_string(),
owned_by: Some("Codex".to_string()),
});
}
return;
};
let Some(id) = string_field(obj, &["slug", "id", "model", "name"]).or_else(|| {
fallback_id
.map(str::trim)
.filter(|id| !id.is_empty())
.map(str::to_string)
}) else {
return;
};
let owned_by = string_field(
obj,
&[
"owned_by", "ownedBy", "provider", "vendor", "category", "owner",
],
)
.or_else(|| Some("Codex".to_string()));
models.push(FetchedModel { id, owned_by });
}
fn string_field(obj: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<String> {
keys.iter()
.filter_map(|key| obj.get(*key))
.filter_map(Value::as_str)
.map(str::trim)
.find(|value| !value.is_empty())
.map(str::to_string)
}
fn truncate_body(body: String) -> String {
if body.chars().count() <= ERROR_BODY_MAX_CHARS {
body
} else {
let mut s: String = body.chars().take(ERROR_BODY_MAX_CHARS).collect();
s.push_str("...");
s
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parse_codex_oauth_models_accepts_openai_style_data() {
let models = parse_models(json!({
"data": [
{ "id": "gpt-5.4", "owned_by": "openai" },
{ "id": "gpt-5.4-mini", "ownedBy": "openai" }
]
}));
assert_eq!(models.len(), 2);
assert_eq!(models[0].id, "gpt-5.4");
assert_eq!(models[0].owned_by.as_deref(), Some("openai"));
assert_eq!(models[1].id, "gpt-5.4-mini");
assert_eq!(models[1].owned_by.as_deref(), Some("openai"));
}
#[test]
fn parse_codex_oauth_models_accepts_model_list_shape() {
let models = parse_models(json!({
"models": [
{ "slug": "gpt-5.3-codex", "display_name": "GPT-5.3 Codex" },
"gpt-5.5"
]
}));
assert_eq!(
models.into_iter().map(|model| model.id).collect::<Vec<_>>(),
vec!["gpt-5.3-codex".to_string(), "gpt-5.5".to_string()]
);
}
#[test]
fn parse_codex_oauth_models_deduplicates_ids() {
let models = parse_models(json!({
"data": [
{ "id": "gpt-5.4" },
{ "model": "gpt-5.4" }
]
}));
assert_eq!(models.len(), 1);
assert_eq!(models[0].id, "gpt-5.4");
}
#[test]
fn parse_codex_oauth_models_accepts_model_map_shape() {
let models = parse_models(json!({
"models": {
"gpt-5.4": { "display_name": "GPT-5.4" },
"gpt-5.5": { "slug": "gpt-5.5" }
}
}));
assert_eq!(
models.into_iter().map(|model| model.id).collect::<Vec<_>>(),
vec!["gpt-5.4".to_string(), "gpt-5.5".to_string()]
);
}
}
+1
View File
@@ -1,4 +1,5 @@
pub mod balance;
pub mod codex_oauth_models;
pub mod coding_plan;
pub mod config;
pub mod env_checker;