Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src-tauri/src/proxy/forwarder/tests/request_building.rs
Original file line number Diff line number Diff line change
Expand Up @@ -806,7 +806,7 @@ async fn codex_oauth_prepare_request_ignores_stale_full_url_and_injects_bound_ac
Some("acc-bound")
);
assert_eq!(header_value(&request, "originator"), Some("codex_cli_rs"));
assert_eq!(header_value(&request, "version"), Some("0.144.1"));
assert_eq!(header_value(&request, "version"), Some("0.153.4"));
assert_eq!(header_value(&request, "anthropic-beta"), None);
assert_eq!(header_value(&request, "anthropic-version"), None);
}
Expand Down
34 changes: 28 additions & 6 deletions src-tauri/src/proxy/providers/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,17 @@ use serde_json::{json, Value};
use crate::{provider::Provider, proxy::error::ProxyError};

use super::{
gemini_shadow::GeminiShadowStore, transform_gemini::AnthropicToolSchemaHints, AuthInfo,
AuthStrategy, ProviderAdapter, ProviderType,
codex_oauth_auth::{CODEX_OAUTH_CLIENT_VERSION, CODEX_OAUTH_ORIGINATOR},
gemini_shadow::GeminiShadowStore,
transform_gemini::AnthropicToolSchemaHints,
AuthInfo, AuthStrategy, ProviderAdapter, ProviderType,
};

pub struct ClaudeAdapter;

const ANTHROPIC_THINKING_PLACEHOLDER: &str = "tool call";
const ANTHROPIC_REDACTED_THINKING_PLACEHOLDER: &str = "[redacted thinking]";
const REASONING_VENDOR_HINTS: &[&str] = &["moonshot", "kimi", "deepseek", "mimo", "xiaomimimo"];
// ChatGPT Codex selects model cohorts from this header pair. Keep both values
// aligned with a real Codex CLI release new enough for the newest preset model.
const CODEX_OAUTH_ORIGINATOR: &str = "codex_cli_rs";
const CODEX_OAUTH_CLIENT_VERSION: &str = "0.144.1";

pub fn get_claude_api_format(provider: &Provider) -> &'static str {
if let Some(meta) = provider.meta.as_ref() {
Expand Down Expand Up @@ -912,6 +910,30 @@ mod tests {
assert!(adapter.needs_transform(&provider));
}

#[test]
fn codex_oauth_generation_uses_gpt6_compatible_identity() {
let request = ClaudeAdapter::new()
.add_auth_headers(
reqwest::Client::new().get("https://example.com"),
&AuthInfo::new("test-token".into(), AuthStrategy::CodexOAuth),
)
.build()
.unwrap();
assert_eq!(request.headers()["authorization"], "Bearer test-token");
assert_eq!(request.headers()["originator"], "codex_cli_rs");
let version: Vec<u32> = request.headers()["version"]
.to_str()
.unwrap()
.split('.')
.map(|part| part.parse().unwrap())
.collect();
// Official rust-v0.153.4 catalog: gpt-6-astra requires 0.153.0.
assert!(
version.as_slice() >= [0, 153, 0].as_slice(),
"gpt-6-astra requires Codex >= 0.153.0; sent {version:?}"
);
}

#[test]
fn gemini_native_oauth_access_token_is_trimmed_and_classified() {
let adapter = ClaudeAdapter::new();
Expand Down
6 changes: 6 additions & 0 deletions src-tauri/src/proxy/providers/codex_oauth_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ const DEVICE_CODE_DEFAULT_EXPIRES_IN: u64 = 900;
const POLLING_SAFETY_MARGIN_SECS: u64 = 3;
const CODEX_USER_AGENT: &str = "cc-switch-codex-oauth";

// Shared by model discovery and generation: ChatGPT gates models by this
// client identity. gpt-6-astra requires >= 0.153.0 in the rust-v0.153.4 catalog.
// Bump together when a new model raises its minimal_client_version.
pub(crate) const CODEX_OAUTH_ORIGINATOR: &str = "codex_cli_rs";
pub(crate) const CODEX_OAUTH_CLIENT_VERSION: &str = "0.153.4";

#[derive(Debug, thiserror::Error)]
pub enum CodexOAuthError {
#[error("等待用户授权中")]
Expand Down
55 changes: 47 additions & 8 deletions src-tauri/src/services/codex_oauth_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,23 @@
//! ChatGPT Codex exposes models through `chatgpt.com/backend-api/codex/models`,
//! which is not an OpenAI-compatible `/v1/models` endpoint.

use crate::proxy::providers::codex_oauth_auth::{
CODEX_OAUTH_CLIENT_VERSION, CODEX_OAUTH_ORIGINATOR,
};
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))
let response = build_models_request(&client, token, account_id)
.send()
.await
.map_err(|e| format!("Request failed: {e}"))?;
Expand All @@ -42,6 +38,21 @@ pub async fn fetch_models_with_token(
Ok(parse_models(value))
}

fn build_models_request(
client: &reqwest::Client,
token: &str,
account_id: &str,
) -> reqwest::RequestBuilder {
client
.get(CODEX_OAUTH_MODELS_URL)
.query(&[("client_version", CODEX_OAUTH_CLIENT_VERSION)])
.header("Authorization", format!("Bearer {token}"))
.header("originator", CODEX_OAUTH_ORIGINATOR)
.header("version", CODEX_OAUTH_CLIENT_VERSION)
.header("chatgpt-account-id", account_id)
.timeout(Duration::from_secs(CODEX_OAUTH_FETCH_TIMEOUT_SECS))
}

fn parse_models(value: Value) -> Vec<FetchedModel> {
let entries = value
.get("data")
Expand Down Expand Up @@ -131,6 +142,34 @@ mod tests {
use super::*;
use serde_json::json;

#[test]
fn codex_oauth_model_discovery_uses_gpt6_compatible_identity() {
let request = build_models_request(&reqwest::Client::new(), "test-token", "test-account")
.build()
.unwrap();
assert_eq!(request.headers()["authorization"], "Bearer test-token");
assert_eq!(request.headers()["chatgpt-account-id"], "test-account");
assert_eq!(request.headers()["originator"], "codex_cli_rs");
let version = request
.url()
.query_pairs()
.find(|(key, _)| key == "client_version")
.unwrap()
.1
.into_owned();
let parts: Vec<u32> = version
.split('.')
.map(|part| part.parse().unwrap())
.collect();
// Official rust-v0.153.4 catalog: gpt-6-astra requires 0.153.0.
assert!(parts.as_slice() >= [0, 153, 0].as_slice());
assert_eq!(request.headers()["version"], version);
let models = parse_models(json!({"models": [{
"slug": "gpt-6-astra", "minimal_client_version": "0.153.0"
}]}));
assert_eq!(models[0].id, "gpt-6-astra");
}

#[test]
fn parse_codex_oauth_models_accepts_openai_style_data() {
let models = parse_models(json!({
Expand Down