Files
CC-Switch/src-tauri/src/proxy/providers/codex.rs
T
Jason 95f2dd4126 feat(codex): preserve OAuth login state during third-party provider switching
Codex provider switches now only write config.toml for third-party providers,
injecting the API key as experimental_bearer_token. The user's auth.json
(ChatGPT OAuth tokens) is preserved. Official providers with login material
still write auth.json normally. Backfill restores bearer tokens into stored
provider auth.OPENAI_API_KEY to maintain canonical shape.
2026-05-25 22:20:34 +08:00

978 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Codex (OpenAI) Provider Adapter
//!
//! 仅透传模式,支持直连 OpenAI API
//!
//! ## 客户端检测
//! 支持检测官方 Codex 客户端 (codex_vscode, codex_cli_rs)
use super::{AuthInfo, AuthStrategy, ProviderAdapter};
use crate::provider::{CodexChatReasoningConfig, Provider};
use crate::proxy::error::ProxyError;
use regex::Regex;
use serde_json::Value as JsonValue;
use std::collections::HashSet;
use std::sync::LazyLock;
use toml::Value as TomlValue;
/// 官方 Codex 客户端 User-Agent 正则
#[allow(dead_code)]
static CODEX_CLIENT_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(codex_vscode|codex_cli_rs)/[\d.]+").unwrap());
/// Codex 适配器
pub struct CodexAdapter;
/// Whether this Codex provider's real upstream should be called through
/// OpenAI Chat Completions, even if the local Codex client is talking to CC
/// Switch through the Responses API.
pub fn codex_provider_uses_chat_completions(provider: &Provider) -> bool {
if let Some(api_format) = provider
.meta
.as_ref()
.and_then(|meta| meta.api_format.as_deref())
.or_else(|| {
provider
.settings_config
.get("api_format")
.and_then(|v| v.as_str())
})
.or_else(|| {
provider
.settings_config
.get("apiFormat")
.and_then(|v| v.as_str())
})
{
return is_chat_wire_api(api_format);
}
if let Some(wire_api) = provider
.settings_config
.get("config")
.and_then(|v| v.as_str())
.and_then(extract_codex_wire_api_from_toml)
{
return is_chat_wire_api(&wire_api);
}
if let Some(base_url) = provider
.settings_config
.get("base_url")
.or_else(|| provider.settings_config.get("baseURL"))
.and_then(|v| v.as_str())
{
return is_chat_completions_url(base_url);
}
provider
.settings_config
.get("config")
.and_then(|v| v.as_str())
.and_then(extract_codex_base_url_from_toml)
.map(|url| is_chat_completions_url(&url))
.unwrap_or(false)
}
pub fn should_convert_codex_responses_to_chat(provider: &Provider, endpoint: &str) -> bool {
let path = endpoint
.split_once('?')
.map_or(endpoint, |(path, _query)| path);
matches!(
path,
"/responses" | "/v1/responses" | "/responses/compact" | "/v1/responses/compact"
) && codex_provider_uses_chat_completions(provider)
}
/// Extract the real upstream model configured for a Codex provider.
pub fn codex_provider_upstream_model(provider: &Provider) -> Option<String> {
provider
.settings_config
.get("model")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|model| !model.is_empty())
.map(ToString::to_string)
.or_else(|| {
provider
.settings_config
.get("config")
.and_then(|v| v.as_str())
.and_then(extract_codex_model_from_toml)
})
}
fn codex_provider_catalog_model_ids(provider: &Provider) -> HashSet<String> {
provider
.settings_config
.get("modelCatalog")
.and_then(|catalog| catalog.get("models"))
.and_then(|models| models.as_array())
.map(|models| {
models
.iter()
.filter_map(|model| model.get("model").and_then(|value| value.as_str()))
.map(str::trim)
.filter(|model| !model.is_empty())
.map(ToString::to_string)
.collect()
})
.unwrap_or_default()
}
/// For Codex Chat providers, ensure the request uses the configured upstream
/// model before converting the request to Chat Completions.
pub fn apply_codex_chat_upstream_model(
provider: &Provider,
body: &mut JsonValue,
) -> Option<String> {
if !codex_provider_uses_chat_completions(provider) {
return None;
}
let catalog_model_ids = codex_provider_catalog_model_ids(provider);
if let Some(request_model) = body
.get("model")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|model| !model.is_empty())
{
if catalog_model_ids.contains(request_model) {
return Some(request_model.to_string());
}
}
let upstream_model = codex_provider_upstream_model(provider)?;
body["model"] = JsonValue::String(upstream_model.clone());
Some(upstream_model)
}
pub fn resolve_codex_chat_reasoning_config(
provider: &Provider,
body: &JsonValue,
) -> Option<CodexChatReasoningConfig> {
if let Some(config) = provider
.meta
.as_ref()
.and_then(|meta| meta.codex_chat_reasoning.clone())
{
return Some(normalize_codex_chat_reasoning_config(config));
}
infer_codex_chat_reasoning_config(provider, body)
}
fn normalize_codex_chat_reasoning_config(
mut config: CodexChatReasoningConfig,
) -> CodexChatReasoningConfig {
if config.supports_effort.unwrap_or(false) && config.supports_thinking.is_none() {
config.supports_thinking = Some(true);
}
config
}
fn infer_codex_chat_reasoning_config(
provider: &Provider,
body: &JsonValue,
) -> Option<CodexChatReasoningConfig> {
let model = body
.get("model")
.and_then(|value| value.as_str())
.map(ToString::to_string)
.or_else(|| codex_provider_upstream_model(provider))
.unwrap_or_default()
.to_ascii_lowercase();
let base_url = provider
.settings_config
.get("base_url")
.or_else(|| provider.settings_config.get("baseURL"))
.and_then(|v| v.as_str())
.map(ToString::to_string)
.or_else(|| {
provider
.settings_config
.get("config")
.and_then(|v| v.as_str())
.and_then(extract_codex_base_url_from_toml)
})
.unwrap_or_default()
.to_ascii_lowercase();
let name = provider.name.to_ascii_lowercase();
// 平台优先:聚合 / 托管平台的 reasoning 接口由平台的推理框架决定,而非模型官方实现,
// 因此先按平台标识(仅 name + base_url,不含 model 名)判定并覆盖模型规则。
if let Some(config) = infer_aggregator_platform_config(&name, &base_url) {
return Some(config);
}
let haystack = format!("{name} {base_url} {model}");
if haystack.contains("deepseek") {
return Some(CodexChatReasoningConfig {
supports_thinking: Some(true),
supports_effort: Some(true),
thinking_param: Some("thinking".to_string()),
effort_param: Some("reasoning_effort".to_string()),
effort_value_mode: Some("deepseek".to_string()),
output_format: Some("reasoning_content".to_string()),
});
}
// StepFun:仅 step-3.5-flash-2603 这一版支持 reasoning effortlow/high 两档),
// 其余 step 模型不暴露 effort,故 supports_effort 仅对含 "2603" 的模型置真。
// 第二个 OR 分支覆盖「经中转/聚合跑该模型、但平台 name/base_url 不含 stepfun」的情况。
if haystack.contains("stepfun") || haystack.contains("step-3.5-flash-2603") {
return Some(CodexChatReasoningConfig {
supports_thinking: Some(true),
supports_effort: Some(model.contains("2603")),
thinking_param: Some("none".to_string()),
effort_param: Some("reasoning_effort".to_string()),
effort_value_mode: Some("low_high".to_string()),
output_format: Some("reasoning".to_string()),
});
}
if haystack.contains("kimi") || haystack.contains("moonshot") {
return Some(CodexChatReasoningConfig {
supports_thinking: Some(true),
supports_effort: Some(false),
thinking_param: Some("thinking".to_string()),
effort_param: Some("none".to_string()),
effort_value_mode: None,
output_format: Some("reasoning_content".to_string()),
});
}
if haystack.contains("glm") || haystack.contains("zhipu") || haystack.contains("z.ai") {
return Some(CodexChatReasoningConfig {
supports_thinking: Some(true),
supports_effort: Some(false),
thinking_param: Some("thinking".to_string()),
effort_param: Some("none".to_string()),
effort_value_mode: None,
output_format: Some("reasoning_content".to_string()),
});
}
if haystack.contains("qwen") || haystack.contains("dashscope") || haystack.contains("bailian") {
return Some(CodexChatReasoningConfig {
supports_thinking: Some(true),
supports_effort: Some(false),
thinking_param: Some("enable_thinking".to_string()),
effort_param: Some("none".to_string()),
effort_value_mode: None,
output_format: Some("reasoning_content".to_string()),
});
}
if haystack.contains("minimax") {
return Some(CodexChatReasoningConfig {
supports_thinking: Some(true),
supports_effort: Some(false),
thinking_param: Some("reasoning_split".to_string()),
effort_param: Some("none".to_string()),
effort_value_mode: None,
output_format: Some("reasoning_details".to_string()),
});
}
if haystack.contains("mimo") {
return Some(CodexChatReasoningConfig {
supports_thinking: Some(true),
supports_effort: Some(false),
thinking_param: Some("thinking".to_string()),
effort_param: Some("none".to_string()),
effort_value_mode: None,
output_format: Some("reasoning_content".to_string()),
});
}
None
}
/// 聚合 / 托管平台的 reasoning 接口由平台决定:同一个模型在不同平台参数可能完全不同
/// DeepSeek 官方用 `thinking:{type}`、SiliconFlow 用 `enable_thinking`、
/// OpenRouter 用原生 `reasoning:{effort}` 对象)。仅以平台标识(name / base_url)判定,
/// 绝不掺入 model 名——model 名属于模型厂商,会把托管平台误判成模型官方接口。
fn infer_aggregator_platform_config(
name: &str,
base_url: &str,
) -> Option<CodexChatReasoningConfig> {
let platform = format!("{name} {base_url}");
// OpenRouter:用原生归一化对象 `reasoning: { effort }`(由 OpenRouter 翻译成各底层
// 模型的正确推理参数,比顶层 OpenAI 别名 reasoning_effort 覆盖面更全)。effort 走
// "openrouter" 值映射:枚举为 xhigh|high|medium|low|minimal,无 max——max 会触发
// `400 reasoning_effort: Invalid option`(见 openclaw#77350),故钳到 xhigh。
// 安全降级:不发 `thinking:{type}`OpenRouter 不认该字段),避免误配导致请求被拒。
if platform.contains("openrouter") {
return Some(CodexChatReasoningConfig {
supports_thinking: Some(false),
supports_effort: Some(true),
thinking_param: Some("none".to_string()),
effort_param: Some("reasoning.effort".to_string()),
effort_value_mode: Some("openrouter".to_string()),
output_format: Some("auto".to_string()),
});
}
// SiliconFlow:平台级统一 `enable_thinking`,思维回传 reasoning_content。
// 安全降级:不按 reasoning_effort 发 effort(平台用 thinking_budget 控制深度,
// 发 reasoning_effort 反而可能不被接受)。
if platform.contains("siliconflow") {
return Some(CodexChatReasoningConfig {
supports_thinking: Some(true),
supports_effort: Some(false),
thinking_param: Some("enable_thinking".to_string()),
effort_param: Some("none".to_string()),
effort_value_mode: None,
output_format: Some("reasoning_content".to_string()),
});
}
None
}
fn is_chat_wire_api(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"chat"
| "chat_completions"
| "chat-completions"
| "openai_chat"
| "openai-chat"
| "openai_chat_completions"
)
}
fn is_chat_completions_url(value: &str) -> bool {
value
.trim_end_matches('/')
.to_ascii_lowercase()
.ends_with("/chat/completions")
}
/// `scheme://host` 之后没有路径段的纯 origin 形式。`build_url` 在这种情况下
/// 会自动补 `/v1`Stream Check 等同步生产路径的代码也需要同一判定。
pub fn is_origin_only_url(value: &str) -> bool {
let trimmed = value.trim_end_matches('/');
match trimmed.split_once("://") {
Some((_scheme, rest)) => !rest.contains('/'),
None => !trimmed.contains('/'),
}
}
fn extract_codex_wire_api_from_toml(config_text: &str) -> Option<String> {
let doc = config_text.parse::<TomlValue>().ok()?;
if let Some(active_provider) = doc.get("model_provider").and_then(|v| v.as_str()) {
if let Some(wire_api) = doc
.get("model_providers")
.and_then(|providers| providers.get(active_provider))
.and_then(|provider| provider.get("wire_api"))
.and_then(|v| v.as_str())
{
return Some(wire_api.to_string());
}
}
doc.get("wire_api")
.and_then(|v| v.as_str())
.map(ToString::to_string)
}
fn extract_codex_model_from_toml(config_text: &str) -> Option<String> {
let doc = config_text.parse::<TomlValue>().ok()?;
doc.get("model")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|model| !model.is_empty())
.map(ToString::to_string)
}
fn extract_codex_base_url_from_toml(config_text: &str) -> Option<String> {
let doc = config_text.parse::<TomlValue>().ok()?;
if let Some(active_provider) = doc.get("model_provider").and_then(|v| v.as_str()) {
if let Some(base_url) = doc
.get("model_providers")
.and_then(|providers| providers.get(active_provider))
.and_then(|provider| provider.get("base_url"))
.and_then(|v| v.as_str())
{
return Some(base_url.to_string());
}
}
doc.get("base_url")
.and_then(|v| v.as_str())
.map(ToString::to_string)
}
impl CodexAdapter {
pub fn new() -> Self {
Self
}
/// 检测是否为官方 Codex 客户端
///
/// 匹配 User-Agent 模式: `^(codex_vscode|codex_cli_rs)/[\d.]+`
#[allow(dead_code)]
pub fn is_official_client(user_agent: &str) -> bool {
CODEX_CLIENT_REGEX.is_match(user_agent)
}
/// 从 Provider 配置中提取 API Key
fn extract_key(&self, provider: &Provider) -> Option<String> {
// 1. 尝试从 env 中获取
if let Some(env) = provider.settings_config.get("env") {
if let Some(key) = env
.get("OPENAI_API_KEY")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|key| !key.is_empty())
{
return Some(key.to_string());
}
}
// 2. 尝试从 auth 中获取 (Codex CLI 格式)
if let Some(auth) = provider.settings_config.get("auth") {
if let Some(key) = crate::codex_config::extract_codex_auth_api_key(auth) {
return Some(key.to_string());
}
}
// 3. 尝试直接获取
if let Some(key) = provider
.settings_config
.get("apiKey")
.or_else(|| provider.settings_config.get("api_key"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|key| !key.is_empty())
{
return Some(key.to_string());
}
// 4. 尝试从 config 对象中获取
if let Some(config) = provider.settings_config.get("config") {
if let Some(key) = config
.get("api_key")
.or_else(|| config.get("apiKey"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|key| !key.is_empty())
{
return Some(key.to_string());
}
if let Some(config_str) = config.as_str() {
if let Some(key) =
crate::codex_config::extract_codex_experimental_bearer_token(config_str)
{
return Some(key);
}
}
}
None
}
}
impl Default for CodexAdapter {
fn default() -> Self {
Self::new()
}
}
impl ProviderAdapter for CodexAdapter {
fn name(&self) -> &'static str {
"Codex"
}
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
// 1. 尝试直接获取 base_url 字段
if let Some(url) = provider
.settings_config
.get("base_url")
.and_then(|v| v.as_str())
{
return Ok(url.trim_end_matches('/').to_string());
}
// 2. 尝试 baseURL
if let Some(url) = provider
.settings_config
.get("baseURL")
.and_then(|v| v.as_str())
{
return Ok(url.trim_end_matches('/').to_string());
}
// 3. 尝试从 config 对象中获取
if let Some(config) = provider.settings_config.get("config") {
if let Some(url) = config.get("base_url").and_then(|v| v.as_str()) {
return Ok(url.trim_end_matches('/').to_string());
}
// 尝试解析 TOML 字符串格式
if let Some(config_str) = config.as_str() {
if let Some(start) = config_str.find("base_url = \"") {
let rest = &config_str[start + 12..];
if let Some(end) = rest.find('"') {
return Ok(rest[..end].trim_end_matches('/').to_string());
}
}
if let Some(start) = config_str.find("base_url = '") {
let rest = &config_str[start + 12..];
if let Some(end) = rest.find('\'') {
return Ok(rest[..end].trim_end_matches('/').to_string());
}
}
}
}
Err(ProxyError::ConfigError(
"Codex Provider 缺少 base_url 配置".to_string(),
))
}
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
self.extract_key(provider)
.map(|key| AuthInfo::new(key, AuthStrategy::Bearer))
}
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
let base_trimmed = base_url.trim_end_matches('/');
let endpoint_trimmed = endpoint.trim_start_matches('/');
// OpenAI/Codex 的 base_url 可能是:
// - 纯 origin: https://api.openai.com (需要自动补 /v1)
// - 已含 /v1: https://api.openai.com/v1 (直接拼接)
// - 自定义前缀: https://xxx/openai (不添加 /v1,直接拼接)
// 检查 base_url 是否已经包含 /v1
let already_has_v1 = base_trimmed.ends_with("/v1");
let origin_only = is_origin_only_url(base_trimmed);
let mut url = if already_has_v1 {
// 已经有 /v1,直接拼接
format!("{base_trimmed}/{endpoint_trimmed}")
} else if origin_only {
// 纯 origin,添加 /v1
format!("{base_trimmed}/v1/{endpoint_trimmed}")
} else {
// 自定义前缀,不添加 /v1,直接拼接
format!("{base_trimmed}/{endpoint_trimmed}")
};
// 去除重复的 /v1/v1(可能由 base_url 与 endpoint 都带版本导致)
while url.contains("/v1/v1") {
url = url.replace("/v1/v1", "/v1");
}
url
}
fn get_auth_headers(
&self,
auth: &AuthInfo,
) -> Result<Vec<(http::HeaderName, http::HeaderValue)>, ProxyError> {
use super::adapter::auth_header_value;
let bearer = format!("Bearer {}", auth.api_key);
Ok(vec![(
http::HeaderName::from_static("authorization"),
auth_header_value(&bearer)?,
)])
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn create_provider(config: serde_json::Value) -> Provider {
Provider {
id: "test".to_string(),
name: "Test Codex".to_string(),
settings_config: config,
website_url: None,
category: Some("codex".to_string()),
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
#[test]
fn test_extract_base_url_direct() {
let adapter = CodexAdapter::new();
let provider = create_provider(json!({
"base_url": "https://api.openai.com/v1"
}));
let url = adapter.extract_base_url(&provider).unwrap();
assert_eq!(url, "https://api.openai.com/v1");
}
#[test]
fn test_extract_auth_from_auth_field() {
let adapter = CodexAdapter::new();
let provider = create_provider(json!({
"auth": {
"OPENAI_API_KEY": "sk-test-key-12345678"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-test-key-12345678");
assert_eq!(auth.strategy, AuthStrategy::Bearer);
}
#[test]
fn test_extract_auth_falls_back_to_config_bearer_when_auth_key_empty() {
let adapter = CodexAdapter::new();
let provider = create_provider(json!({
"auth": {
"OPENAI_API_KEY": ""
},
"config": r#"model_provider = "custom"
[model_providers.custom]
experimental_bearer_token = "sk-config-key"
"#
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-config-key");
assert_eq!(auth.strategy, AuthStrategy::Bearer);
}
#[test]
fn test_extract_auth_from_env() {
let adapter = CodexAdapter::new();
let provider = create_provider(json!({
"env": {
"OPENAI_API_KEY": "sk-env-key-12345678"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-env-key-12345678");
}
#[test]
fn test_build_url() {
let adapter = CodexAdapter::new();
let url = adapter.build_url("https://api.openai.com/v1", "/responses");
assert_eq!(url, "https://api.openai.com/v1/responses");
}
#[test]
fn test_build_url_origin_adds_v1() {
let adapter = CodexAdapter::new();
let url = adapter.build_url("https://api.openai.com", "/responses");
assert_eq!(url, "https://api.openai.com/v1/responses");
}
#[test]
fn test_build_url_custom_prefix_no_v1() {
let adapter = CodexAdapter::new();
let url = adapter.build_url("https://example.com/openai", "/responses");
assert_eq!(url, "https://example.com/openai/responses");
}
#[test]
fn test_build_url_dedup_v1() {
let adapter = CodexAdapter::new();
// base_url 已包含 /v1endpoint 也包含 /v1
let url = adapter.build_url("https://www.packyapi.com/v1", "/v1/responses");
assert_eq!(url, "https://www.packyapi.com/v1/responses");
}
// 官方客户端检测测试
#[test]
fn test_is_official_client_vscode() {
assert!(CodexAdapter::is_official_client("codex_vscode/1.0.0"));
assert!(CodexAdapter::is_official_client("codex_vscode/2.3.4"));
assert!(CodexAdapter::is_official_client("codex_vscode/0.1"));
}
#[test]
fn test_is_official_client_cli() {
assert!(CodexAdapter::is_official_client("codex_cli_rs/1.0.0"));
assert!(CodexAdapter::is_official_client("codex_cli_rs/0.5.2"));
}
#[test]
fn test_is_not_official_client() {
assert!(!CodexAdapter::is_official_client("Mozilla/5.0"));
assert!(!CodexAdapter::is_official_client("curl/7.68.0"));
assert!(!CodexAdapter::is_official_client("python-requests/2.25.1"));
assert!(!CodexAdapter::is_official_client("codex_other/1.0.0"));
assert!(!CodexAdapter::is_official_client(""));
}
#[test]
fn test_is_official_client_partial_match() {
// 必须从开头匹配
assert!(!CodexAdapter::is_official_client("some codex_vscode/1.0.0"));
assert!(!CodexAdapter::is_official_client(
"prefix_codex_cli_rs/1.0.0"
));
}
#[test]
fn test_codex_provider_uses_chat_completions_from_active_wire_api() {
let provider = create_provider(json!({
"config": r#"
model_provider = "chat_only"
model = "gpt-5"
[model_providers.chat_only]
name = "Chat Only"
base_url = "https://example.com/v1"
wire_api = "chat"
"#
}));
assert!(codex_provider_uses_chat_completions(&provider));
assert!(should_convert_codex_responses_to_chat(
&provider,
"/responses?stream=true"
));
assert!(!should_convert_codex_responses_to_chat(
&provider,
"/chat/completions"
));
}
#[test]
fn test_codex_provider_uses_chat_completions_from_full_chat_url() {
let provider = create_provider(json!({
"base_url": "https://example.com/v1/chat/completions"
}));
assert!(codex_provider_uses_chat_completions(&provider));
assert!(should_convert_codex_responses_to_chat(
&provider,
"/v1/responses/compact"
));
}
#[test]
fn test_codex_provider_uses_chat_completions_from_meta_api_format_for_compact() {
let mut provider = create_provider(json!({
"base_url": "https://example.com/v1"
}));
provider.meta = Some(crate::provider::ProviderMeta {
api_format: Some("openai_chat".to_string()),
..Default::default()
});
assert!(codex_provider_uses_chat_completions(&provider));
assert!(should_convert_codex_responses_to_chat(
&provider,
"/responses/compact?stream=true"
));
}
#[test]
fn test_codex_provider_uses_chat_completions_from_meta_api_format_for_responses() {
let mut provider = create_provider(json!({
"base_url": "https://api.deepseek.com/v1"
}));
provider.meta = Some(crate::provider::ProviderMeta {
api_format: Some("openai_chat".to_string()),
..Default::default()
});
assert!(should_convert_codex_responses_to_chat(
&provider,
"/v1/responses"
));
}
#[test]
fn test_apply_codex_chat_upstream_model_uses_provider_config_model() {
let mut provider = create_provider(json!({
"config": r#"
model_provider = "deepseek"
model = "deepseek-v4-flash"
[model_providers.deepseek]
name = "DeepSeek"
base_url = "https://api.deepseek.com/v1"
wire_api = "responses"
"#
}));
provider.meta = Some(crate::provider::ProviderMeta {
api_format: Some("openai_chat".to_string()),
..Default::default()
});
let mut body = json!({
"model": "placeholder-client-model",
"input": "ping"
});
let upstream_model = apply_codex_chat_upstream_model(&provider, &mut body);
assert_eq!(upstream_model.as_deref(), Some("deepseek-v4-flash"));
assert_eq!(
body.get("model").and_then(|v| v.as_str()),
Some("deepseek-v4-flash")
);
}
#[test]
fn test_apply_codex_chat_upstream_model_preserves_catalog_model_selection() {
let mut provider = create_provider(json!({
"config": r#"
model_provider = "deepseek"
model = "deepseek-v4-flash"
[model_providers.deepseek]
name = "DeepSeek"
base_url = "https://api.deepseek.com/v1"
wire_api = "responses"
"#,
"modelCatalog": {
"models": [
{ "model": "deepseek-v4-flash" },
{ "model": "kimi-k2" }
]
}
}));
provider.meta = Some(crate::provider::ProviderMeta {
api_format: Some("openai_chat".to_string()),
..Default::default()
});
let mut body = json!({
"model": "kimi-k2",
"input": "ping"
});
let upstream_model = apply_codex_chat_upstream_model(&provider, &mut body);
assert_eq!(upstream_model.as_deref(), Some("kimi-k2"));
assert_eq!(body.get("model").and_then(|v| v.as_str()), Some("kimi-k2"));
}
#[test]
fn test_resolve_codex_chat_reasoning_infers_deepseek_effort_support() {
let provider = create_provider(json!({
"config": r#"
model_provider = "deepseek"
model = "deepseek-v4-pro"
[model_providers.deepseek]
name = "DeepSeek"
base_url = "https://api.deepseek.com"
wire_api = "chat"
"#
}));
let config =
resolve_codex_chat_reasoning_config(&provider, &json!({ "model": "deepseek-v4-pro" }))
.unwrap();
assert_eq!(config.supports_thinking, Some(true));
assert_eq!(config.supports_effort, Some(true));
assert_eq!(config.effort_value_mode.as_deref(), Some("deepseek"));
}
#[test]
fn test_resolve_codex_chat_reasoning_explicit_meta_overrides_inference() {
let mut provider = create_provider(json!({
"config": r#"
model_provider = "deepseek"
model = "deepseek-v4-pro"
[model_providers.deepseek]
name = "DeepSeek"
base_url = "https://api.deepseek.com"
wire_api = "chat"
"#
}));
provider.meta = Some(crate::provider::ProviderMeta {
codex_chat_reasoning: Some(CodexChatReasoningConfig {
supports_thinking: Some(false),
supports_effort: Some(false),
thinking_param: Some("none".to_string()),
effort_param: Some("none".to_string()),
effort_value_mode: None,
output_format: Some("auto".to_string()),
}),
..Default::default()
});
let config =
resolve_codex_chat_reasoning_config(&provider, &json!({ "model": "deepseek-v4-pro" }))
.unwrap();
assert_eq!(config.supports_thinking, Some(false));
assert_eq!(config.supports_effort, Some(false));
assert_eq!(config.thinking_param.as_deref(), Some("none"));
}
#[test]
fn test_resolve_codex_chat_reasoning_openrouter_platform_overrides_model() {
let provider = create_provider(json!({
"config": r#"
model_provider = "openrouter"
model = "deepseek/deepseek-chat-v3.1"
[model_providers.openrouter]
name = "OpenRouter"
base_url = "https://openrouter.ai/api/v1"
wire_api = "chat"
"#
}));
// 模型名含 "deepseek",但平台是 OpenRouter —— 平台规则必须覆盖模型规则。
let config = resolve_codex_chat_reasoning_config(
&provider,
&json!({ "model": "deepseek/deepseek-chat-v3.1" }),
)
.unwrap();
assert_eq!(config.thinking_param.as_deref(), Some("none"));
assert_eq!(config.effort_param.as_deref(), Some("reasoning.effort"));
assert_eq!(config.effort_value_mode.as_deref(), Some("openrouter"));
assert_eq!(config.supports_effort, Some(true));
}
#[test]
fn test_resolve_codex_chat_reasoning_siliconflow_platform_overrides_minimax() {
let provider = create_provider(json!({
"config": r#"
model_provider = "siliconflow"
model = "MiniMaxAI/MiniMax-M2.7"
[model_providers.siliconflow]
name = "SiliconFlow"
base_url = "https://api.siliconflow.cn/v1"
wire_api = "chat"
"#
}));
// 模型是 MiniMax(官方用 reasoning_split),但平台是 SiliconFlow —— 应走平台的 enable_thinking。
let config = resolve_codex_chat_reasoning_config(
&provider,
&json!({ "model": "MiniMaxAI/MiniMax-M2.7" }),
)
.unwrap();
assert_eq!(config.thinking_param.as_deref(), Some("enable_thinking"));
assert_eq!(config.supports_effort, Some(false));
assert_eq!(config.output_format.as_deref(), Some("reasoning_content"));
}
}