Files
CC-Switch/src-tauri/src/proxy/providers/codex.rs
T
Jason dbb5bd1537 feat(codex): xAI (Grok) OAuth managed provider with native Responses compat
Add a managed "xAI (Grok) OAuth" Codex provider that routes through the
local proxy to api.x.ai via the shared Grok CLI OAuth identity, plus the
native-Responses compatibility layer that makes Codex 0.142+ traffic work
against xAI's strict upstream serde parser.

Provider:
- codex.rs: recognize the xai_oauth placeholder in extract_auth, hard-pin
  the base URL to api.x.ai and the tool profile to native Responses
- forwarder.rs: treat xAI OAuth auth failures as non-retryable
- presets + ProviderForm/CodexFormFields: managed OAuth preset that hides
  the api key/endpoint fields and derives the provider type across apps

Native Responses compatibility (gated on is_xai_oauth, so no other
provider is affected):
- transform_codex_responses_namespace: flatten Codex's private
  namespace/plugin tool declarations into top-level function tools on the
  request; restore the flat function_call names back to {name, namespace}
  on the response (streaming and non-streaming) so the client matches its
  own namespaced tool registry
- transform_codex_responses_xai_sanitize: strip the OpenAI-backend-private
  fields xAI rejects (external_web_access, prompt_cache_retention,
  safety_identifier, the additional_tools carrier, tool_search, ...) with
  deterministic removals that keep the prompt-cache prefix stable
- wire both into the native passthrough after the request transform;
  response restore runs in a dedicated handler so the generic passthrough
  hot path is untouched

Ports the proven approach of sub2api's Grok Responses gateway. Verified
with a 4-round codex -> xAI OAuth workload: all tasks green, zero upstream
errors.
2026-07-21 16:39:34 +08:00

1612 lines
55 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)
}
/// Whether a converted Codex Responses request may send `prompt_cache_key` to
/// its Chat Completions upstream. Unknown OpenAI-compatible gateways default to
/// false because many reject unsupported request fields with HTTP 400.
pub fn should_send_codex_chat_prompt_cache_key(provider: &Provider) -> bool {
match provider
.meta
.as_ref()
.and_then(|meta| meta.prompt_cache_routing.as_deref())
.unwrap_or("auto")
{
"enabled" => return true,
"disabled" => return false,
_ => {}
}
let base_url = provider
.settings_config
.get("base_url")
.or_else(|| provider.settings_config.get("baseURL"))
.and_then(|value| value.as_str())
.map(ToString::to_string)
.or_else(|| {
provider
.settings_config
.get("config")
.and_then(|value| value.as_str())
.and_then(extract_codex_base_url_from_toml)
});
let Some(base_url) = base_url else {
return false;
};
let Ok(url) = url::Url::parse(&base_url) else {
return false;
};
match url.host_str() {
Some("api.openai.com") => true,
Some("api.kimi.com") => {
let path = url.path().trim_end_matches('/');
path == "/coding" || path.starts_with("/coding/")
}
_ => false,
}
}
/// Add a stable cache-routing key after Responses -> Chat conversion. An
/// explicit client key wins; otherwise only a real client-provided session ID
/// is eligible. Generated per-request UUIDs must never be used here.
pub fn inject_codex_chat_prompt_cache_key(
provider: &Provider,
chat_body: &mut JsonValue,
explicit_key: Option<&str>,
client_session_id: Option<&str>,
) -> bool {
if !should_send_codex_chat_prompt_cache_key(provider) {
return false;
}
let key = explicit_key
.map(str::trim)
.filter(|key| !key.is_empty())
.or_else(|| {
client_session_id
.map(str::trim)
.filter(|session_id| !session_id.is_empty())
});
let Some(key) = key else {
return false;
};
chat_body["prompt_cache_key"] = JsonValue::String(key.to_string());
true
}
/// Whether this Codex provider's real upstream speaks the native Anthropic
/// Messages protocol (`/v1/messages`). The local Codex client always talks to CC
/// Switch through the Responses API, so CC Switch bridges Responses ⇄ Anthropic.
///
/// Determined solely from explicit config (apiFormat / wire_api); no base_url
/// guessing — Anthropic gateway addresses vary widely and guessing easily misfires.
pub fn codex_provider_uses_anthropic(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_anthropic_wire_api(api_format);
}
provider
.settings_config
.get("config")
.and_then(|v| v.as_str())
.and_then(extract_codex_wire_api_from_toml)
.map(|wire_api| is_anthropic_wire_api(&wire_api))
.unwrap_or(false)
}
pub fn should_convert_codex_responses_to_anthropic(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_anthropic(provider)
}
/// Whether a native-Responses Codex upstream needs Codex `namespace`/plugin
/// tool declarations flattened before forwarding.
///
/// Codex 0.142+ emits ChatGPT-backend-private `{"type":"namespace",…}` tool
/// shapes that strict third-party Responses gateways reject with
/// `422 unknown variant "namespace"`. Only providers whose upstream is such a
/// strict native gateway need the flatten+restore pass; the Chat/Anthropic
/// transform paths already unwrap namespaces on their own. Currently that is the
/// managed xAI (Grok) OAuth provider — the first strict gateway cc-switch hit.
pub fn provider_needs_responses_namespace_flatten(provider: &Provider) -> bool {
provider.is_xai_oauth()
}
/// The single built-in official Codex provider. Unlike managed Codex OAuth
/// providers used by Claude, this route receives authentication from the
/// calling Codex client (`requires_openai_auth = true`).
pub fn is_codex_official_provider(provider: &Provider) -> bool {
provider.id == crate::database::CODEX_OFFICIAL_PROVIDER_ID
&& provider.category.as_deref() == Some("official")
}
/// Resolve the model-catalog tool profile for a Codex provider using the SAME
/// Anthropic detection as the proxy router ([`codex_provider_uses_anthropic`]), so the
/// generated catalog never disagrees with the routed transform. A provider whose
/// Anthropic upstream is declared only via settings `apiFormat` or TOML `wire_api`
/// (not `meta.api_format`) would otherwise get a `ProxyChat` catalog and emit the
/// freeform `apply_patch` tool that the Anthropic transform then silently drops.
/// Non-Anthropic providers keep the existing `meta.api_format` classification.
pub fn resolve_codex_catalog_tool_profile(
provider: &Provider,
) -> crate::codex_config::CodexCatalogToolProfile {
use crate::codex_config::CodexCatalogToolProfile;
if is_codex_official_provider(provider) {
return CodexCatalogToolProfile::NativeResponses;
}
// xAI OAuth pins the native Responses profile regardless of editable
// api_format, mirroring the Claude-side managed-provider invariant.
if provider.is_xai_oauth() {
return CodexCatalogToolProfile::NativeResponses;
}
if codex_provider_uses_anthropic(provider) {
return CodexCatalogToolProfile::Anthropic;
}
CodexCatalogToolProfile::from_api_format(
provider.meta.as_ref().and_then(|m| m.api_format.as_deref()),
)
}
/// 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(|config| {
crate::grok_config::extract_model_config(config)
.map(|model| model.model)
.or_else(|| extract_codex_model_from_toml(config))
})
})
}
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;
}
apply_codex_upstream_model(provider, body)
}
/// Same model-substitution logic as `apply_codex_chat_upstream_model`, but without
/// the chat gating check. Reused by the anthropic conversion path (the forwarder has
/// already confirmed this provider uses anthropic).
pub fn apply_codex_upstream_model(provider: &Provider, body: &mut JsonValue) -> Option<String> {
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_anthropic_wire_api(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"anthropic" | "anthropic_messages" | "anthropic-messages" | "claude" | "messages"
)
}
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> {
// Canonical parser lives in codex_config; keep this thin alias so the
// proxy hot path and the usage-credential resolver share one implementation.
crate::codex_config::extract_codex_base_url(config_text)
}
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::grok_config::extract_credentials(config_str) {
return Some(key);
}
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> {
if is_codex_official_provider(provider) {
return Ok(super::CHATGPT_CODEX_BASE_URL.to_string());
}
// xAI OAuth: ignore editable provider base URLs and always use the xAI
// API origin associated with the managed token.
if provider.is_xai_oauth() {
return Ok(super::XAI_API_BASE_URL.to_string());
}
// 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(url) = crate::grok_config::extract_base_url(config_str) {
return Ok(url.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());
}
}
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> {
// xAI OAuth (Grok subscription): placeholder credentials only; the real
// access_token is resolved per-request by the forwarder via XaiOAuthManager.
if provider.is_xai_oauth() {
return Some(AuthInfo::new(
"xai_oauth_placeholder".to_string(),
AuthStrategy::XaiOAuth,
));
}
// Anthropic upstream: the auth field is chosen by the user in the UI (meta.apiKeyField).
// ANTHROPIC_API_KEY → x-api-key (AuthStrategy::Anthropic)
// ANTHROPIC_AUTH_TOKEN → Authorization: Bearer (default, AuthStrategy::Bearer)
// The two are mutually exclusive to avoid a 401 from the gateway receiving
// both auth headers at once. All other Codex upstreams stay pure Bearer.
let strategy = if codex_provider_uses_anthropic(provider) {
let uses_x_api_key = provider
.meta
.as_ref()
.and_then(|meta| meta.api_key_field.as_deref())
.map(|field| field.eq_ignore_ascii_case("ANTHROPIC_API_KEY"))
.unwrap_or(false);
if uses_x_api_key {
AuthStrategy::Anthropic
} else {
AuthStrategy::Bearer
}
} else {
AuthStrategy::Bearer
};
self.extract_key(provider)
.map(|key| AuthInfo::new(key, strategy))
}
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);
// Anthropic gateway: send only x-api-key (anthropic-version is filled in by
// the forwarder). Mutually exclusive with Bearer to avoid a 401 from the
// gateway receiving both auth headers at once.
if auth.strategy == AuthStrategy::Anthropic {
return Ok(vec![(
http::HeaderName::from_static("x-api-key"),
auth_header_value(&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 grok_build_toml_exposes_upstream_credentials_and_model() {
let adapter = CodexAdapter::new();
let provider = create_provider(json!({
"config": r#"
[models]
default = "grok-4.5"
[model."grok-4.5"]
model = "upstream-grok-model"
base_url = "https://relay.example.com/v1/"
name = "Example Relay"
api_key = "grok-secret"
api_backend = "responses"
context_window = 500000
"#
}));
assert_eq!(
adapter.extract_base_url(&provider).unwrap(),
"https://relay.example.com/v1"
);
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "grok-secret");
assert_eq!(auth.strategy, AuthStrategy::Bearer);
assert_eq!(
codex_provider_upstream_model(&provider).as_deref(),
Some("upstream-grok-model")
);
}
#[test]
fn official_provider_uses_fixed_chatgpt_backend_without_stored_key() {
let mut provider = create_provider(json!({ "auth": {}, "config": "" }));
provider.id = "codex-official".to_string();
provider.category = Some("official".to_string());
let adapter = CodexAdapter::new();
assert!(is_codex_official_provider(&provider));
assert_eq!(
adapter
.extract_base_url(&provider)
.expect("official base url"),
"https://chatgpt.com/backend-api/codex"
);
assert!(adapter.extract_auth(&provider).is_none());
assert_eq!(
adapter.build_url(
"https://chatgpt.com/backend-api/codex",
"/responses/compact"
),
"https://chatgpt.com/backend-api/codex/responses/compact"
);
}
#[test]
fn prompt_cache_routing_auto_enables_known_upstreams_only() {
let kimi = create_provider(json!({
"config": r#"
model_provider = "custom"
[model_providers.custom]
base_url = "https://api.kimi.com/coding/v1"
wire_api = "responses"
"#
}));
let openai = create_provider(json!({
"base_url": "https://api.openai.com/v1"
}));
let unknown = create_provider(json!({
"base_url": "https://strict.example.com/v1"
}));
assert!(should_send_codex_chat_prompt_cache_key(&kimi));
assert!(should_send_codex_chat_prompt_cache_key(&openai));
assert!(!should_send_codex_chat_prompt_cache_key(&unknown));
}
#[test]
fn prompt_cache_routing_user_override_wins_over_auto_detection() {
let mut kimi = create_provider(json!({
"base_url": "https://api.kimi.com/coding/v1"
}));
kimi.meta = Some(crate::provider::ProviderMeta {
prompt_cache_routing: Some("disabled".to_string()),
..Default::default()
});
assert!(!should_send_codex_chat_prompt_cache_key(&kimi));
let mut unknown = create_provider(json!({
"base_url": "https://strict.example.com/v1"
}));
unknown.meta = Some(crate::provider::ProviderMeta {
prompt_cache_routing: Some("enabled".to_string()),
..Default::default()
});
assert!(should_send_codex_chat_prompt_cache_key(&unknown));
}
#[test]
fn prompt_cache_key_prefers_explicit_key_then_real_session() {
let provider = create_provider(json!({
"base_url": "https://api.kimi.com/coding/v1"
}));
let mut explicit_body = json!({ "model": "kimi-for-coding" });
assert!(inject_codex_chat_prompt_cache_key(
&provider,
&mut explicit_body,
Some("request-key"),
Some("session-key"),
));
assert_eq!(explicit_body["prompt_cache_key"], "request-key");
let mut session_body = json!({ "model": "kimi-for-coding" });
assert!(inject_codex_chat_prompt_cache_key(
&provider,
&mut session_body,
None,
Some("session-key"),
));
assert_eq!(session_body["prompt_cache_key"], "session-key");
}
#[test]
fn prompt_cache_key_is_not_injected_without_real_session_or_support() {
let kimi = create_provider(json!({
"base_url": "https://api.kimi.com/coding/v1"
}));
let mut no_session_body = json!({ "model": "kimi-for-coding" });
assert!(!inject_codex_chat_prompt_cache_key(
&kimi,
&mut no_session_body,
None,
None,
));
assert!(no_session_body.get("prompt_cache_key").is_none());
let unknown = create_provider(json!({
"base_url": "https://strict.example.com/v1"
}));
let mut unsupported_body = json!({ "model": "other" });
assert!(!inject_codex_chat_prompt_cache_key(
&unknown,
&mut unsupported_body,
Some("request-key"),
Some("session-key"),
));
assert!(unsupported_body.get("prompt_cache_key").is_none());
}
#[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");
}
// ==================== anthropic upstream detection ====================
#[test]
fn test_uses_anthropic_from_settings_api_format() {
let provider = create_provider(json!({ "apiFormat": "anthropic" }));
assert!(codex_provider_uses_anthropic(&provider));
let provider = create_provider(json!({ "api_format": "anthropic_messages" }));
assert!(codex_provider_uses_anthropic(&provider));
}
#[test]
fn test_uses_anthropic_from_meta_api_format() {
let mut provider = create_provider(json!({}));
provider.meta = Some(crate::provider::ProviderMeta {
api_format: Some("anthropic".to_string()),
..Default::default()
});
assert!(codex_provider_uses_anthropic(&provider));
}
#[test]
fn test_uses_anthropic_from_toml_wire_api() {
let provider = create_provider(json!({
"config": r#"model_provider = "custom"
[model_providers.custom]
wire_api = "anthropic"
"#
}));
assert!(codex_provider_uses_anthropic(&provider));
}
#[test]
fn test_anthropic_false_for_chat_and_responses() {
let chat = create_provider(json!({ "apiFormat": "openai_chat" }));
assert!(!codex_provider_uses_anthropic(&chat));
let responses = create_provider(json!({ "apiFormat": "openai_responses" }));
assert!(!codex_provider_uses_anthropic(&responses));
}
#[test]
fn test_anthropic_and_chat_are_mutually_exclusive() {
let anth = create_provider(json!({ "apiFormat": "anthropic" }));
assert!(codex_provider_uses_anthropic(&anth));
assert!(!codex_provider_uses_chat_completions(&anth));
let chat = create_provider(json!({ "apiFormat": "openai_chat" }));
assert!(codex_provider_uses_chat_completions(&chat));
assert!(!codex_provider_uses_anthropic(&chat));
}
#[test]
fn test_should_convert_responses_to_anthropic_path_guard() {
let provider = create_provider(json!({ "apiFormat": "anthropic" }));
assert!(should_convert_codex_responses_to_anthropic(
&provider,
"/responses"
));
assert!(should_convert_codex_responses_to_anthropic(
&provider,
"/v1/responses/compact"
));
assert!(should_convert_codex_responses_to_anthropic(
&provider,
"/responses?x=1"
));
assert!(!should_convert_codex_responses_to_anthropic(
&provider,
"/chat/completions"
));
}
#[test]
fn test_resolve_catalog_profile_matches_router() {
use crate::codex_config::CodexCatalogToolProfile;
// Anthropic declared only via TOML wire_api (no meta.api_format) must still
// resolve to the Anthropic catalog profile — this is the routing/catalog
// divergence that let apply_patch leak through.
let toml_anthropic = create_provider(json!({
"config": r#"model_provider = "custom"
[model_providers.custom]
wire_api = "anthropic"
"#
}));
assert_eq!(
resolve_codex_catalog_tool_profile(&toml_anthropic),
CodexCatalogToolProfile::Anthropic
);
// Anthropic via settings apiFormat.
let settings_anthropic = create_provider(json!({ "apiFormat": "anthropic" }));
assert_eq!(
resolve_codex_catalog_tool_profile(&settings_anthropic),
CodexCatalogToolProfile::Anthropic
);
// Native openai_responses (meta) → NativeResponses; chat → ProxyChat.
let mut native = create_provider(json!({}));
native.meta = Some(crate::provider::ProviderMeta {
api_format: Some("openai_responses".to_string()),
..Default::default()
});
assert_eq!(
resolve_codex_catalog_tool_profile(&native),
CodexCatalogToolProfile::NativeResponses
);
let chat = create_provider(json!({ "apiFormat": "openai_chat" }));
assert_eq!(
resolve_codex_catalog_tool_profile(&chat),
CodexCatalogToolProfile::ProxyChat
);
}
#[test]
fn test_apply_codex_upstream_model_preserves_one_m_catalog_model() {
// Regression for the [1m] path: a request model carrying the [1m] marker must
// match its catalog entry and be preserved (not overridden by the provider
// default) so the transform can later strip [1m] and emit the context-1m beta.
// This only works because the forwarder no longer strips [1m] before this call
// on the Anthropic path.
let provider = create_provider(json!({
"config": r#"model_provider = "custom"
model = "claude-opus-4-1"
[model_providers.custom]
wire_api = "anthropic"
"#,
"modelCatalog": {
"models": [
{ "model": "claude-opus-4-1[1m]" }
]
}
}));
let mut body = json!({ "model": "claude-opus-4-1[1m]", "input": "hi" });
let result = apply_codex_upstream_model(&provider, &mut body);
assert_eq!(result.as_deref(), Some("claude-opus-4-1[1m]"));
assert_eq!(
body.get("model").and_then(|v| v.as_str()),
Some("claude-opus-4-1[1m]")
);
}
#[test]
fn test_anthropic_auth_defaults_to_bearer() {
// No meta.apiKeyField (defaults to ANTHROPIC_AUTH_TOKEN) → Authorization: Bearer only
let adapter = CodexAdapter::new();
let provider = create_provider(json!({
"apiFormat": "anthropic",
"auth": { "OPENAI_API_KEY": "sk-anthropic-key-123" }
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.strategy, AuthStrategy::Bearer);
let headers = adapter.get_auth_headers(&auth).unwrap();
let names: Vec<String> = headers
.iter()
.map(|(name, _)| name.as_str().to_string())
.collect();
assert_eq!(names, vec!["authorization".to_string()]);
}
#[test]
fn test_anthropic_auth_x_api_key_when_selected() {
// meta.apiKeyField = ANTHROPIC_API_KEY → x-api-key only
let adapter = CodexAdapter::new();
let mut provider = create_provider(json!({
"apiFormat": "anthropic",
"auth": { "OPENAI_API_KEY": "sk-anthropic-key-123" }
}));
provider.meta = Some(crate::provider::ProviderMeta {
api_format: Some("anthropic".to_string()),
api_key_field: Some("ANTHROPIC_API_KEY".to_string()),
..Default::default()
});
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
let headers = adapter.get_auth_headers(&auth).unwrap();
let names: Vec<String> = headers
.iter()
.map(|(name, _)| name.as_str().to_string())
.collect();
assert_eq!(names, vec!["x-api-key".to_string()]);
}
#[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"));
}
#[test]
fn xai_oauth_invariants_ignore_editable_base_url_and_auth() {
let adapter = CodexAdapter::new();
let mut provider = create_provider(json!({
"auth": { "OPENAI_API_KEY": "user-edited" },
"config": r#"
model = "grok-4.5"
[model_providers.custom]
name = "xai"
base_url = "https://attacker.example/v1"
wire_api = "responses"
"#
}));
provider.meta = Some(crate::provider::ProviderMeta {
provider_type: Some("xai_oauth".to_string()),
..Default::default()
});
// 可编辑字段(base_url / auth key)不得影响托管路由:
// 端点硬定向 api.x.ai,凭据是占位符(真 token 由 forwarder 注入)。
assert_eq!(
adapter.extract_base_url(&provider).unwrap(),
super::super::XAI_API_BASE_URL
);
let auth = adapter
.extract_auth(&provider)
.expect("managed auth placeholder");
assert_eq!(auth.api_key, "xai_oauth_placeholder");
assert_eq!(auth.strategy, AuthStrategy::XaiOAuth);
}
#[test]
fn xai_oauth_pins_native_responses_catalog_profile() {
let mut provider = create_provider(json!({ "auth": {}, "config": "" }));
provider.meta = Some(crate::provider::ProviderMeta {
provider_type: Some("xai_oauth".to_string()),
// 即使 api_format 被改成 anthropiccatalog 画像也必须钉死原生 Responses
api_format: Some("anthropic".to_string()),
..Default::default()
});
assert!(matches!(
resolve_codex_catalog_tool_profile(&provider),
crate::codex_config::CodexCatalogToolProfile::NativeResponses
));
}
#[test]
fn namespace_flatten_gate_only_fires_for_xai_oauth() {
// xAI OAuth: strict native gateway → needs namespace flattening.
let mut xai = create_provider(json!({ "auth": {}, "config": "" }));
xai.meta = Some(crate::provider::ProviderMeta {
provider_type: Some("xai_oauth".to_string()),
..Default::default()
});
assert!(provider_needs_responses_namespace_flatten(&xai));
// A plain third-party API-key Codex provider must not be flattened.
let plain = create_provider(json!({
"auth": { "OPENAI_API_KEY": "sk-x" },
"config": "base_url = \"https://api.x.ai/v1\"\nwire_api = \"responses\""
}));
assert!(!provider_needs_responses_namespace_flatten(&plain));
}
}