mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat: adaptive reasoning detection for Codex Chat providers
Auto-detect each Chat-routed Codex provider's reasoning interface from
its name, base URL, and model, then inject the matching thinking
parameter without manual configuration:
- Platform-first inference (OpenRouter, SiliconFlow) overrides model
rules, since the same model exposes different reasoning controls
depending on the hosting platform.
- Effort tiers are forwarded only to providers that support them
(DeepSeek, OpenRouter, and StepFun's step-3.5-flash-2603); on/off-only
providers (Kimi, GLM, Qwen, MiniMax, MiMo, SiliconFlow) drop the level
instead of sending a field the upstream rejects.
- OpenRouter uses the native reasoning:{effort} object, clamps max to
xhigh (its enum has no max), and forwards an explicit effort:"none" so
reasoning can be turned off.
- StepFun falls back to inference so per-model effort support is honored
(the static preset would have forced effort on step-3.5-flash too).
Includes the Codex provider-form reasoning controls, i18n strings
(zh/en/ja), and response-side reasoning extraction.
This commit is contained in:
@@ -261,6 +261,26 @@ pub struct ClaudeDesktopModelRoute {
|
||||
pub supports_1m: Option<bool>,
|
||||
}
|
||||
|
||||
/// Codex Responses -> Chat Completions 的 reasoning 能力描述。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
pub struct CodexChatReasoningConfig {
|
||||
#[serde(rename = "supportsThinking", skip_serializing_if = "Option::is_none")]
|
||||
pub supports_thinking: Option<bool>,
|
||||
#[serde(rename = "supportsEffort", skip_serializing_if = "Option::is_none")]
|
||||
pub supports_effort: Option<bool>,
|
||||
#[serde(rename = "thinkingParam", skip_serializing_if = "Option::is_none")]
|
||||
pub thinking_param: Option<String>,
|
||||
#[serde(rename = "effortParam", skip_serializing_if = "Option::is_none")]
|
||||
pub effort_param: Option<String>,
|
||||
#[serde(rename = "effortValueMode", skip_serializing_if = "Option::is_none")]
|
||||
pub effort_value_mode: Option<String>,
|
||||
/// 声明性字段:标注上游 reasoning 的回传位置(reasoning_content / reasoning /
|
||||
/// reasoning_details / think_tags)。当前响应侧 `extract_reasoning_field_text`
|
||||
/// 靠穷举字段提取、并不读取本字段;保留作文档说明与未来按格式分发(如 think_tags)的预留。
|
||||
#[serde(rename = "outputFormat", skip_serializing_if = "Option::is_none")]
|
||||
pub output_format: Option<String>,
|
||||
}
|
||||
|
||||
/// 供应商元数据
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProviderMeta {
|
||||
@@ -339,6 +359,9 @@ pub struct ProviderMeta {
|
||||
/// Codex OAuth FAST mode: inject `service_tier = "priority"` for ChatGPT Codex requests.
|
||||
#[serde(rename = "codexFastMode", skip_serializing_if = "Option::is_none")]
|
||||
pub codex_fast_mode: Option<bool>,
|
||||
/// Codex Responses -> Chat Completions reasoning capability metadata.
|
||||
#[serde(rename = "codexChatReasoning", skip_serializing_if = "Option::is_none")]
|
||||
pub codex_chat_reasoning: Option<CodexChatReasoningConfig>,
|
||||
/// 累加模式应用中,该 provider 是否已写入 live config。
|
||||
/// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。
|
||||
#[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -1160,7 +1160,12 @@ impl RequestForwarder {
|
||||
);
|
||||
}
|
||||
super::providers::apply_codex_chat_upstream_model(provider, &mut mapped_body);
|
||||
super::providers::transform_codex_chat::responses_to_chat_completions(mapped_body)?
|
||||
let reasoning_config =
|
||||
super::providers::resolve_codex_chat_reasoning_config(provider, &mapped_body);
|
||||
super::providers::transform_codex_chat::responses_to_chat_completions_with_reasoning(
|
||||
mapped_body,
|
||||
reasoning_config.as_ref(),
|
||||
)?
|
||||
} else if needs_transform {
|
||||
if adapter.name() == "Claude" {
|
||||
let api_format = resolved_claude_api_format
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! 支持检测官方 Codex 客户端 (codex_vscode, codex_cli_rs)
|
||||
|
||||
use super::{AuthInfo, AuthStrategy, ProviderAdapter};
|
||||
use crate::provider::Provider;
|
||||
use crate::provider::{CodexChatReasoningConfig, Provider};
|
||||
use crate::proxy::error::ProxyError;
|
||||
use regex::Regex;
|
||||
use serde_json::Value as JsonValue;
|
||||
@@ -147,6 +147,192 @@ pub fn apply_codex_chat_upstream_model(
|
||||
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 effort(low/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(),
|
||||
@@ -642,4 +828,114 @@ wire_api = "responses"
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ use serde_json::{json, Map, Value};
|
||||
const THINK_OPEN_TAG: &str = "<think>";
|
||||
const THINK_CLOSE_TAG: &str = "</think>";
|
||||
|
||||
// 穷举上游可能的 reasoning 回传字段,优先级:reasoning_content > reasoning(字符串/对象) > reasoning_details。
|
||||
// 不依赖 provider meta 的 outputFormat 声明,因此对各家 Chat 兼容接口都能兜底提取。
|
||||
pub(crate) fn extract_reasoning_field_text(value: &Value) -> Option<String> {
|
||||
for key in ["reasoning_content", "reasoning"] {
|
||||
if let Some(text) = value.get(key).and_then(|v| v.as_str()) {
|
||||
@@ -12,15 +14,61 @@ pub(crate) fn extract_reasoning_field_text(value: &Value) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
let reasoning = value.get("reasoning")?;
|
||||
for key in ["content", "text", "summary"] {
|
||||
if let Some(text) = reasoning.get(key).and_then(|v| v.as_str()) {
|
||||
if let Some(reasoning) = value.get("reasoning") {
|
||||
for key in ["content", "text", "summary"] {
|
||||
if let Some(text) = reasoning.get(key).and_then(|v| v.as_str()) {
|
||||
if !text.is_empty() {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(details) = value.get("reasoning_details") {
|
||||
if let Some(text) = extract_reasoning_details_text(details) {
|
||||
return Some(text);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_reasoning_details_text(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(text) => (!text.is_empty()).then(|| text.to_string()),
|
||||
Value::Array(parts) => {
|
||||
let text = parts
|
||||
.iter()
|
||||
.filter_map(extract_reasoning_detail_part_text)
|
||||
.filter(|text| !text.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
(!text.is_empty()).then_some(text)
|
||||
}
|
||||
Value::Object(_) => extract_reasoning_detail_part_text(value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_reasoning_detail_part_text(value: &Value) -> Option<String> {
|
||||
for key in ["text", "content", "summary"] {
|
||||
if let Some(text) = value.get(key).and_then(|v| v.as_str()) {
|
||||
if !text.is_empty() {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(parts) = value.get("parts").and_then(|v| v.as_array()) {
|
||||
let text = parts
|
||||
.iter()
|
||||
.filter_map(extract_reasoning_detail_part_text)
|
||||
.filter(|text| !text.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
return (!text.is_empty()).then_some(text);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ pub use claude::{
|
||||
pub use codex::CodexAdapter;
|
||||
pub use codex::{
|
||||
apply_codex_chat_upstream_model, codex_provider_upstream_model,
|
||||
codex_provider_uses_chat_completions, is_origin_only_url,
|
||||
codex_provider_uses_chat_completions, is_origin_only_url, resolve_codex_chat_reasoning_config,
|
||||
should_convert_codex_responses_to_chat,
|
||||
};
|
||||
pub use gemini::GeminiAdapter;
|
||||
|
||||
@@ -8,6 +8,7 @@ use super::codex_chat_common::{
|
||||
append_reasoning_content, extract_reasoning_field_text, extract_reasoning_summary_text,
|
||||
response_function_call_item, split_leading_think_block,
|
||||
};
|
||||
use crate::provider::CodexChatReasoningConfig;
|
||||
use crate::proxy::{
|
||||
error::ProxyError,
|
||||
json_canonical::{canonical_json_string, canonicalize_json_string_if_parseable},
|
||||
@@ -31,7 +32,17 @@ const EXTRA_CHAT_PASSTHROUGH_FIELDS: &[&str] = &[
|
||||
"user",
|
||||
];
|
||||
/// Convert an OpenAI Responses request into an OpenAI Chat Completions request.
|
||||
#[allow(dead_code)]
|
||||
pub fn responses_to_chat_completions(body: Value) -> Result<Value, ProxyError> {
|
||||
responses_to_chat_completions_with_reasoning(body, None)
|
||||
}
|
||||
|
||||
/// Convert an OpenAI Responses request into an OpenAI Chat Completions request,
|
||||
/// using provider-declared Codex Chat reasoning capabilities when available.
|
||||
pub fn responses_to_chat_completions_with_reasoning(
|
||||
body: Value,
|
||||
reasoning_config: Option<&CodexChatReasoningConfig>,
|
||||
) -> Result<Value, ProxyError> {
|
||||
let mut result = json!({});
|
||||
|
||||
if let Some(model) = body.get("model") {
|
||||
@@ -76,11 +87,7 @@ pub fn responses_to_chat_completions(body: Value) -> Result<Value, ProxyError> {
|
||||
}
|
||||
}
|
||||
|
||||
if super::transform::supports_reasoning_effort(model) {
|
||||
if let Some(effort) = body.pointer("/reasoning/effort") {
|
||||
result["reasoning_effort"] = effort.clone();
|
||||
}
|
||||
}
|
||||
apply_reasoning_options(&mut result, &body, model, reasoning_config);
|
||||
|
||||
if let Some(tools) = body.get("tools").and_then(|v| v.as_array()) {
|
||||
let tools: Vec<Value> = tools
|
||||
@@ -125,6 +132,150 @@ pub fn responses_to_chat_completions(body: Value) -> Result<Value, ProxyError> {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn apply_reasoning_options(
|
||||
result: &mut Value,
|
||||
body: &Value,
|
||||
model: &str,
|
||||
config: Option<&CodexChatReasoningConfig>,
|
||||
) {
|
||||
let Some(config) = config else {
|
||||
if super::transform::supports_reasoning_effort(model) {
|
||||
if let Some(effort) = body.pointer("/reasoning/effort") {
|
||||
result["reasoning_effort"] = effort.clone();
|
||||
}
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
let supports_effort = config.supports_effort.unwrap_or(false);
|
||||
let supports_thinking = config.supports_thinking.unwrap_or(false) || supports_effort;
|
||||
let Some(reasoning_enabled) = reasoning_requested(body) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if supports_thinking {
|
||||
match config
|
||||
.thinking_param
|
||||
.as_deref()
|
||||
.unwrap_or("thinking")
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"thinking" => {
|
||||
result["thinking"] = json!({
|
||||
"type": if reasoning_enabled { "enabled" } else { "disabled" }
|
||||
});
|
||||
}
|
||||
"enable_thinking" => {
|
||||
result["enable_thinking"] = json!(reasoning_enabled);
|
||||
}
|
||||
"reasoning_split" => {
|
||||
result["reasoning_split"] = json!(reasoning_enabled);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// effort_param 在 early return 之前算出:reasoning.effort 形态的「显式关闭」分支要用到。
|
||||
let effort_param = config
|
||||
.effort_param
|
||||
.as_deref()
|
||||
.unwrap_or("reasoning_effort")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
|
||||
if !reasoning_enabled {
|
||||
// OpenRouter 原生 reasoning.effort 支持显式 "none"(语义:彻底关闭推理)。
|
||||
// 上游显式发 effort=none/off/disabled(或 reasoning=null)时 reasoning_enabled 为 false,
|
||||
// 直接 return 会丢失关闭意图——OpenRouter 部分模型默认开思考,不带字段无法关闭,
|
||||
// 造成行为与成本偏差;故对该形态忠实转发 {"reasoning":{"effort":"none"}}。
|
||||
// 顶层 reasoning_effort 平台的枚举不含 none,仍走上方 thinking 关闭路径、不发 effort。
|
||||
// 注意:完全不带 reasoning 字段时 reasoning_requested 返回 None 已提前 return,
|
||||
// 不会走到这里,故只有上游「显式」表达关闭才透传 none。
|
||||
if effort_param == "reasoning.effort" {
|
||||
result["reasoning"] = json!({ "effort": "none" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if !supports_effort {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(effort) = body.pointer("/reasoning/effort").and_then(|v| v.as_str()) else {
|
||||
return;
|
||||
};
|
||||
let Some(mapped) = map_reasoning_effort(effort, config.effort_value_mode.as_deref()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
match effort_param.as_str() {
|
||||
// OpenAI 风格顶层字段(DeepSeek 官方、OpenAI o-series 等)。
|
||||
"reasoning_effort" => {
|
||||
result["reasoning_effort"] = json!(mapped);
|
||||
}
|
||||
// OpenRouter 原生归一化对象:reasoning.effort 会被 OpenRouter 翻译成各底层模型
|
||||
// (OpenAI/Grok/Gemini/Anthropic)的正确推理参数,覆盖面比顶层 OpenAI 别名更全。
|
||||
// 本转换从空对象构造、不残留原始 reasoning 对象,故不会出现 reasoning 与
|
||||
// reasoning_effort 并存触发 400 的情况(参见 openclaw#24119)。
|
||||
"reasoning.effort" => {
|
||||
result["reasoning"] = json!({ "effort": mapped });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn reasoning_requested(body: &Value) -> Option<bool> {
|
||||
if let Some(effort) = body.pointer("/reasoning/effort").and_then(|v| v.as_str()) {
|
||||
return Some(!matches!(
|
||||
effort.trim().to_ascii_lowercase().as_str(),
|
||||
"none" | "off" | "disabled"
|
||||
));
|
||||
}
|
||||
|
||||
body.get("reasoning").map(|value| !value.is_null())
|
||||
}
|
||||
|
||||
fn map_reasoning_effort(effort: &str, mode: Option<&str>) -> Option<&'static str> {
|
||||
let effort = effort.trim().to_ascii_lowercase();
|
||||
if matches!(effort.as_str(), "none" | "off" | "disabled") {
|
||||
return None;
|
||||
}
|
||||
|
||||
match mode.unwrap_or("passthrough") {
|
||||
"deepseek" => match effort.as_str() {
|
||||
"max" | "xhigh" => Some("max"),
|
||||
_ => Some("high"),
|
||||
},
|
||||
"low_high" => match effort.as_str() {
|
||||
"minimal" | "low" => Some("low"),
|
||||
_ => Some("high"),
|
||||
},
|
||||
// OpenRouter effort 枚举为 xhigh|high|medium|low|minimal(无 max)。max 是
|
||||
// Codex / 部分模型的扩展档位,对 OpenRouter 非法,会触发
|
||||
// `400 reasoning_effort: Invalid option`(见 openclaw#77350);钳到最高合法档
|
||||
// xhigh,其余合法值透传,未知值丢弃以免被上游拒绝。
|
||||
"openrouter" => match effort.as_str() {
|
||||
"max" | "xhigh" => Some("xhigh"),
|
||||
"high" => Some("high"),
|
||||
"medium" => Some("medium"),
|
||||
"low" => Some("low"),
|
||||
"minimal" => Some("minimal"),
|
||||
_ => None,
|
||||
},
|
||||
_ => match effort.as_str() {
|
||||
"minimal" => Some("minimal"),
|
||||
"low" => Some("low"),
|
||||
"medium" => Some("medium"),
|
||||
"high" => Some("high"),
|
||||
"xhigh" => Some("xhigh"),
|
||||
"max" => Some("max"),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// MiniMax 严格要求 messages 中只能首条出现 `role=system`,
|
||||
/// 否则返回 `invalid params, chat content has invalid message role: system (2013)`。
|
||||
/// 把所有 system 消息合并到首位,避免中间 system(如 Codex 的 `developer` 指令)触发该约束;
|
||||
@@ -1107,6 +1258,195 @@ mod tests {
|
||||
assert_eq!(result["reasoning_effort"], "high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_uses_provider_reasoning_effort_for_deepseek_model() {
|
||||
let input = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"input": "hello",
|
||||
"reasoning": {"effort": "xhigh"}
|
||||
});
|
||||
let config = 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()),
|
||||
};
|
||||
|
||||
let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap();
|
||||
|
||||
assert_eq!(result["thinking"]["type"], "enabled");
|
||||
assert_eq!(result["reasoning_effort"], "max");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_maps_openrouter_to_native_reasoning_object() {
|
||||
// OpenRouter 平台形态:原生 reasoning:{effort} 对象 + "openrouter" 值映射
|
||||
// (与 infer_aggregator_platform_config 推断出的配置保持一致)。
|
||||
let config = 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()),
|
||||
};
|
||||
|
||||
// max 不在 OpenRouter 枚举内(见 openclaw#77350),必须钳成 xhigh,
|
||||
// 且写进原生 reasoning 对象,而非顶层 reasoning_effort 别名。
|
||||
let input = json!({
|
||||
"model": "deepseek/deepseek-chat-v3.1",
|
||||
"input": "hello",
|
||||
"reasoning": {"effort": "max"}
|
||||
});
|
||||
let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap();
|
||||
|
||||
assert_eq!(result["reasoning"]["effort"], "xhigh");
|
||||
assert!(result.get("reasoning_effort").is_none());
|
||||
// thinking_param=none:即使 supports_effort 把 supports_thinking 带成 true,
|
||||
// 也不写任何 thinking 字段(OpenRouter 不认 thinking:{type})。
|
||||
assert!(result.get("thinking").is_none());
|
||||
|
||||
// 合法档位原样透传。
|
||||
let input_high = json!({
|
||||
"model": "deepseek/deepseek-chat-v3.1",
|
||||
"input": "hello",
|
||||
"reasoning": {"effort": "high"}
|
||||
});
|
||||
let result_high =
|
||||
responses_to_chat_completions_with_reasoning(input_high, Some(&config)).unwrap();
|
||||
assert_eq!(result_high["reasoning"]["effort"], "high");
|
||||
assert!(result_high.get("reasoning_effort").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_passes_explicit_none_through_for_openrouter() {
|
||||
// OpenRouter 原生 reasoning 对象支持显式关闭:effort=none 应忠实转发为
|
||||
// {"reasoning":{"effort":"none"}},而非被吞掉——否则默认开思考的模型无法关闭,
|
||||
// 带来行为与成本偏差。
|
||||
let config = 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()),
|
||||
};
|
||||
|
||||
let input = json!({
|
||||
"model": "openai/gpt-5",
|
||||
"input": "hello",
|
||||
"reasoning": {"effort": "none"}
|
||||
});
|
||||
let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap();
|
||||
|
||||
assert_eq!(result["reasoning"]["effort"], "none");
|
||||
// none 不是 OpenAI 顶层 reasoning_effort 的合法枚举,不写顶层别名;也不写 thinking。
|
||||
assert!(result.get("reasoning_effort").is_none());
|
||||
assert!(result.get("thinking").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_drops_explicit_none_for_top_level_effort_provider() {
|
||||
// 对照:顶层 reasoning_effort 平台(DeepSeek/OpenAI 风格)的 effort 枚举不含 none,
|
||||
// 显式 none 不应透传成 reasoning_effort:"none"(会被上游拒),仅走 thinking 关闭路径。
|
||||
// 锁定「none 透传仅限 reasoning.effort 形态」的边界,防止回归。
|
||||
let config = 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()),
|
||||
};
|
||||
|
||||
let input = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"input": "hello",
|
||||
"reasoning": {"effort": "none"}
|
||||
});
|
||||
let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap();
|
||||
|
||||
// thinking 关闭信号照发;但不写 reasoning_effort,也不写原生 reasoning 对象。
|
||||
assert_eq!(result["thinking"]["type"], "disabled");
|
||||
assert!(result.get("reasoning_effort").is_none());
|
||||
assert!(result.get("reasoning").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_maps_thinking_only_provider_without_effort() {
|
||||
let input = json!({
|
||||
"model": "kimi-k2.6",
|
||||
"input": "hello",
|
||||
"reasoning": {"effort": "high"}
|
||||
});
|
||||
let config = 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()),
|
||||
};
|
||||
|
||||
let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap();
|
||||
|
||||
assert_eq!(result["thinking"]["type"], "enabled");
|
||||
assert!(result.get("reasoning_effort").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_maps_enable_thinking_provider() {
|
||||
let input = json!({
|
||||
"model": "qwen3-max",
|
||||
"input": "hello",
|
||||
"reasoning": {"effort": "medium"}
|
||||
});
|
||||
let config = 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()),
|
||||
};
|
||||
|
||||
let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap();
|
||||
|
||||
assert_eq!(result["enable_thinking"], true);
|
||||
assert!(result.get("reasoning_effort").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_response_to_responses_extracts_reasoning_details() {
|
||||
let input = json!({
|
||||
"id": "chatcmpl_minimax",
|
||||
"object": "chat.completion",
|
||||
"created": 123,
|
||||
"model": "MiniMax-M2.7",
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"reasoning_details": [
|
||||
{"type": "reasoning_text", "text": "Need to inspect the code."}
|
||||
],
|
||||
"content": "Done"
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}]
|
||||
});
|
||||
|
||||
let result = chat_completion_to_response(input).unwrap();
|
||||
|
||||
assert_eq!(result["output"][0]["type"], "reasoning");
|
||||
assert_eq!(
|
||||
result["output"][0]["summary"][0]["text"],
|
||||
"Need to inspect the code."
|
||||
);
|
||||
assert_eq!(result["output"][1]["content"][0]["text"], "Done");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_normalizes_codex_internal_roles() {
|
||||
let input = json!({
|
||||
|
||||
@@ -4,8 +4,20 @@ import { Button } from "@/components/ui/button";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { toast } from "sonner";
|
||||
import { Download, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Loader2,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||
import { ApiKeySection, EndpointField, ModelDropdown } from "./shared";
|
||||
import {
|
||||
@@ -16,6 +28,7 @@ import {
|
||||
import type {
|
||||
CodexApiFormat,
|
||||
CodexCatalogModel,
|
||||
CodexChatReasoning,
|
||||
ProviderCategory,
|
||||
} from "@/types";
|
||||
|
||||
@@ -50,6 +63,8 @@ interface CodexFormFieldsProps {
|
||||
// Note: wire_api is always "responses" for Codex; apiFormat controls proxy-layer conversion
|
||||
apiFormat: CodexApiFormat;
|
||||
onApiFormatChange: (format: CodexApiFormat) => void;
|
||||
codexChatReasoning?: CodexChatReasoning;
|
||||
onCodexChatReasoningChange?: (value: CodexChatReasoning) => void;
|
||||
|
||||
// Model Catalog
|
||||
catalogModels?: CodexCatalogModel[];
|
||||
@@ -108,6 +123,8 @@ export function CodexFormFields({
|
||||
onAutoSelectChange,
|
||||
apiFormat,
|
||||
onApiFormatChange,
|
||||
codexChatReasoning = {},
|
||||
onCodexChatReasoningChange,
|
||||
catalogModels = [],
|
||||
onCatalogModelsChange,
|
||||
speedTestEndpoints,
|
||||
@@ -116,8 +133,14 @@ export function CodexFormFields({
|
||||
|
||||
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
|
||||
const [isFetchingModels, setIsFetchingModels] = useState(false);
|
||||
const [reasoningExpanded, setReasoningExpanded] = useState(false);
|
||||
const needsLocalRouting = apiFormat === "openai_chat";
|
||||
const canEditCatalog = Boolean(onCatalogModelsChange);
|
||||
const canEditReasoning = Boolean(onCodexChatReasoningChange);
|
||||
const supportsThinking =
|
||||
codexChatReasoning.supportsThinking === true ||
|
||||
codexChatReasoning.supportsEffort === true;
|
||||
const supportsEffort = codexChatReasoning.supportsEffort === true;
|
||||
|
||||
const [catalogRows, setCatalogRows] = useState<CodexCatalogRow[]>(() =>
|
||||
catalogModels.map((m) => createCatalogRow(m)),
|
||||
@@ -157,6 +180,33 @@ export function CodexFormFields({
|
||||
[onApiFormatChange],
|
||||
);
|
||||
|
||||
const handleReasoningThinkingChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
if (!onCodexChatReasoningChange) return;
|
||||
onCodexChatReasoningChange({
|
||||
...codexChatReasoning,
|
||||
supportsThinking: checked,
|
||||
supportsEffort: checked ? codexChatReasoning.supportsEffort : false,
|
||||
});
|
||||
},
|
||||
[codexChatReasoning, onCodexChatReasoningChange],
|
||||
);
|
||||
|
||||
const handleReasoningEffortChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
if (!onCodexChatReasoningChange) return;
|
||||
onCodexChatReasoningChange({
|
||||
...codexChatReasoning,
|
||||
supportsThinking: checked ? true : codexChatReasoning.supportsThinking,
|
||||
supportsEffort: checked,
|
||||
effortParam: checked
|
||||
? (codexChatReasoning.effortParam ?? "reasoning_effort")
|
||||
: "none",
|
||||
});
|
||||
},
|
||||
[codexChatReasoning, onCodexChatReasoningChange],
|
||||
);
|
||||
|
||||
const handleFetchModels = useCallback(() => {
|
||||
if (!codexBaseUrl || !codexApiKey) {
|
||||
showFetchModelsError(null, t, {
|
||||
@@ -303,6 +353,87 @@ export function CodexFormFields({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsLocalRouting && canEditReasoning && (
|
||||
<Collapsible
|
||||
open={reasoningExpanded}
|
||||
onOpenChange={setReasoningExpanded}
|
||||
className="rounded-lg border border-border-default p-4"
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant={null}
|
||||
size="sm"
|
||||
className="h-8 w-full justify-start gap-1.5 px-0 text-sm font-medium text-foreground hover:opacity-70"
|
||||
>
|
||||
{reasoningExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
{t("codexConfig.reasoningSectionToggle", {
|
||||
defaultValue: "思考能力(高级·通常自动识别)",
|
||||
})}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
{!reasoningExpanded && (
|
||||
<p className="mt-1 ml-1 text-xs text-muted-foreground">
|
||||
{t("codexConfig.reasoningSectionHint", {
|
||||
defaultValue:
|
||||
"预设供应商已自动配置;自定义供应商会按名称/地址自动推断。仅当自动识别不准时才需展开手动覆盖。",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<CollapsibleContent className="space-y-3 pt-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<FormLabel>
|
||||
{t("codexConfig.reasoningModeToggle", {
|
||||
defaultValue: "支持思考模式",
|
||||
})}
|
||||
</FormLabel>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("codexConfig.reasoningModeHint", {
|
||||
defaultValue:
|
||||
"上游 Chat Completions 接口支持开启或关闭 thinking 时启用。Kimi、GLM、Qwen 等通常属于这一类。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={supportsThinking}
|
||||
onCheckedChange={handleReasoningThinkingChange}
|
||||
aria-label={t("codexConfig.reasoningModeToggle", {
|
||||
defaultValue: "支持思考模式",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 border-t border-border-default pt-3">
|
||||
<div className="space-y-1">
|
||||
<FormLabel>
|
||||
{t("codexConfig.reasoningEffortToggle", {
|
||||
defaultValue: "支持思考等级",
|
||||
})}
|
||||
</FormLabel>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("codexConfig.reasoningEffortHint", {
|
||||
defaultValue:
|
||||
"上游支持 low/high/max 等思考深度控制时启用。启用后会自动启用思考模式,并把 Codex 的 reasoning.effort 转成上游 Chat 参数。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={supportsEffort}
|
||||
onCheckedChange={handleReasoningEffortChange}
|
||||
aria-label={t("codexConfig.reasoningEffortToggle", {
|
||||
defaultValue: "支持思考等级",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* Codex 模型映射 —— 仅在本地路由 + 可编辑时显示 */}
|
||||
{needsLocalRouting && canEditCatalog && (
|
||||
<div className="space-y-4 rounded-lg border border-border-default p-4">
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
ClaudeApiFormat,
|
||||
CodexApiFormat,
|
||||
CodexCatalogModel,
|
||||
CodexChatReasoning,
|
||||
ClaudeApiKeyField,
|
||||
} from "@/types";
|
||||
import {
|
||||
@@ -174,6 +175,41 @@ export const normalizeCodexCatalogModelsForSave = (
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const normalizeCodexChatReasoningForSave = (
|
||||
value?: CodexChatReasoning,
|
||||
): CodexChatReasoning | undefined => {
|
||||
const supportsEffort = value?.supportsEffort === true;
|
||||
const supportsThinking = value?.supportsThinking === true || supportsEffort;
|
||||
const hasExplicitConfig = value && Object.keys(value).length > 0;
|
||||
|
||||
if (!supportsThinking && !supportsEffort) {
|
||||
return hasExplicitConfig
|
||||
? {
|
||||
supportsThinking: false,
|
||||
supportsEffort: false,
|
||||
thinkingParam: "none",
|
||||
effortParam: "none",
|
||||
outputFormat: value?.outputFormat ?? "auto",
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
supportsThinking,
|
||||
supportsEffort,
|
||||
thinkingParam: supportsThinking
|
||||
? (value?.thinkingParam ?? "thinking")
|
||||
: "none",
|
||||
effortParam: supportsEffort
|
||||
? (value?.effortParam ?? "reasoning_effort")
|
||||
: "none",
|
||||
effortValueMode: supportsEffort
|
||||
? (value?.effortValueMode ?? "passthrough")
|
||||
: undefined,
|
||||
outputFormat: value?.outputFormat ?? "auto",
|
||||
};
|
||||
};
|
||||
|
||||
export interface ProviderFormProps {
|
||||
appId: AppId;
|
||||
providerId?: string;
|
||||
@@ -316,6 +352,7 @@ function ProviderFormFull({
|
||||
initialData?.meta?.pricingModelSource,
|
||||
),
|
||||
});
|
||||
setCodexChatReasoning(initialData?.meta?.codexChatReasoning ?? {});
|
||||
}, [appId, initialData, supportsFullUrl]);
|
||||
|
||||
const defaultValues: ProviderFormData = useMemo(
|
||||
@@ -466,6 +503,10 @@ function ProviderFormFull({
|
||||
const [codexFastMode, setCodexFastMode] = useState<boolean>(
|
||||
() => initialData?.meta?.codexFastMode ?? false,
|
||||
);
|
||||
const [codexChatReasoning, setCodexChatReasoning] =
|
||||
useState<CodexChatReasoning>(
|
||||
() => initialData?.meta?.codexChatReasoning ?? {},
|
||||
);
|
||||
|
||||
const {
|
||||
codexAuth,
|
||||
@@ -530,6 +571,7 @@ function ProviderFormFull({
|
||||
if (appId === "codex" && !initialData && selectedPresetId === "custom") {
|
||||
const template = getCodexCustomTemplate();
|
||||
resetCodexConfig(template.auth, template.config);
|
||||
setCodexChatReasoning({});
|
||||
}
|
||||
}, [appId, initialData, selectedPresetId, resetCodexConfig]);
|
||||
|
||||
@@ -1336,6 +1378,12 @@ function ProviderFormFull({
|
||||
? selectedGitHubAccountId
|
||||
: undefined,
|
||||
codexFastMode: isCodexOauthProvider ? codexFastMode : undefined,
|
||||
codexChatReasoning:
|
||||
appId === "codex" &&
|
||||
category !== "official" &&
|
||||
localCodexApiFormat === "openai_chat"
|
||||
? normalizeCodexChatReasoningForSave(codexChatReasoning)
|
||||
: undefined,
|
||||
testConfig: testConfig.enabled ? testConfig : undefined,
|
||||
costMultiplier: pricingConfig.enabled
|
||||
? pricingConfig.costMultiplier
|
||||
@@ -1473,6 +1521,7 @@ function ProviderFormFull({
|
||||
if (appId === "codex") {
|
||||
const template = getCodexCustomTemplate();
|
||||
resetCodexConfig(template.auth, template.config);
|
||||
setCodexChatReasoning({});
|
||||
setLocalCodexApiFormat(
|
||||
codexApiFormatFromWireApi(extractCodexWireApi(template.config)) ??
|
||||
"openai_responses",
|
||||
@@ -1513,6 +1562,7 @@ function ProviderFormFull({
|
||||
const config = preset.config ?? "";
|
||||
|
||||
resetCodexConfig(auth, config, preset.modelCatalog ?? []);
|
||||
setCodexChatReasoning(preset.codexChatReasoning ?? {});
|
||||
setLocalCodexApiFormat(
|
||||
preset.apiFormat ??
|
||||
codexApiFormatFromWireApi(extractCodexWireApi(config)) ??
|
||||
@@ -1984,6 +2034,8 @@ function ProviderFormFull({
|
||||
onAutoSelectChange={setEndpointAutoSelect}
|
||||
apiFormat={localCodexApiFormat}
|
||||
onApiFormatChange={handleCodexApiFormatChange}
|
||||
codexChatReasoning={codexChatReasoning}
|
||||
onCodexChatReasoningChange={setCodexChatReasoning}
|
||||
catalogModels={codexCatalogModels}
|
||||
onCatalogModelsChange={setCodexCatalogModels}
|
||||
speedTestEndpoints={speedTestEndpoints}
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
* Codex 预设供应商配置模板
|
||||
*/
|
||||
import { ProviderCategory } from "../types";
|
||||
import type { CodexApiFormat, CodexCatalogModel } from "../types";
|
||||
import type {
|
||||
CodexApiFormat,
|
||||
CodexCatalogModel,
|
||||
CodexChatReasoning,
|
||||
} from "../types";
|
||||
import type { PresetTheme } from "./claudeProviderPresets";
|
||||
|
||||
export interface CodexProviderPreset {
|
||||
@@ -29,6 +33,8 @@ export interface CodexProviderPreset {
|
||||
apiFormat?: CodexApiFormat;
|
||||
// Codex Chat 本地路由模式下的模型目录
|
||||
modelCatalog?: CodexCatalogModel[];
|
||||
// Codex Responses -> Chat Completions reasoning capability defaults
|
||||
codexChatReasoning?: CodexChatReasoning;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,6 +267,14 @@ requires_openai_auth = true`,
|
||||
contextWindow: 1000000,
|
||||
},
|
||||
]),
|
||||
codexChatReasoning: {
|
||||
supportsThinking: true,
|
||||
supportsEffort: true,
|
||||
thinkingParam: "thinking",
|
||||
effortParam: "reasoning_effort",
|
||||
effortValueMode: "deepseek",
|
||||
outputFormat: "reasoning_content",
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "deepseek",
|
||||
iconColor: "#1E88E5",
|
||||
@@ -280,6 +294,13 @@ requires_openai_auth = true`,
|
||||
modelCatalog: modelCatalog([
|
||||
{ model: "glm-5", displayName: "GLM-5", contextWindow: 200000 },
|
||||
]),
|
||||
codexChatReasoning: {
|
||||
supportsThinking: true,
|
||||
supportsEffort: false,
|
||||
thinkingParam: "thinking",
|
||||
effortParam: "none",
|
||||
outputFormat: "reasoning_content",
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "zhipu",
|
||||
iconColor: "#0F62FE",
|
||||
@@ -299,6 +320,13 @@ requires_openai_auth = true`,
|
||||
modelCatalog: modelCatalog([
|
||||
{ model: "glm-5", displayName: "GLM-5", contextWindow: 200000 },
|
||||
]),
|
||||
codexChatReasoning: {
|
||||
supportsThinking: true,
|
||||
supportsEffort: false,
|
||||
thinkingParam: "thinking",
|
||||
effortParam: "none",
|
||||
outputFormat: "reasoning_content",
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "zhipu",
|
||||
iconColor: "#0F62FE",
|
||||
@@ -347,6 +375,13 @@ requires_openai_auth = true`,
|
||||
},
|
||||
{ model: "qwen3-max", displayName: "Qwen3 Max", contextWindow: 262144 },
|
||||
]),
|
||||
codexChatReasoning: {
|
||||
supportsThinking: true,
|
||||
supportsEffort: false,
|
||||
thinkingParam: "enable_thinking",
|
||||
effortParam: "none",
|
||||
outputFormat: "reasoning_content",
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "bailian",
|
||||
iconColor: "#624AFF",
|
||||
@@ -366,6 +401,13 @@ requires_openai_auth = true`,
|
||||
modelCatalog: modelCatalog([
|
||||
{ model: "kimi-k2.6", displayName: "Kimi K2.6", contextWindow: 262144 },
|
||||
]),
|
||||
codexChatReasoning: {
|
||||
supportsThinking: true,
|
||||
supportsEffort: false,
|
||||
thinkingParam: "thinking",
|
||||
effortParam: "none",
|
||||
outputFormat: "reasoning_content",
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "kimi",
|
||||
iconColor: "#6366F1",
|
||||
@@ -445,6 +487,13 @@ requires_openai_auth = true`,
|
||||
contextWindow: 200000,
|
||||
},
|
||||
]),
|
||||
codexChatReasoning: {
|
||||
supportsThinking: true,
|
||||
supportsEffort: false,
|
||||
thinkingParam: "thinking",
|
||||
effortParam: "none",
|
||||
outputFormat: "reasoning_content",
|
||||
},
|
||||
category: "aggregator",
|
||||
icon: "modelscope",
|
||||
iconColor: "#624AFF",
|
||||
@@ -491,6 +540,13 @@ requires_openai_auth = true`,
|
||||
contextWindow: 200000,
|
||||
},
|
||||
]),
|
||||
codexChatReasoning: {
|
||||
supportsThinking: true,
|
||||
supportsEffort: false,
|
||||
thinkingParam: "reasoning_split",
|
||||
effortParam: "none",
|
||||
outputFormat: "reasoning_details",
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "minimax_cn",
|
||||
@@ -520,6 +576,13 @@ requires_openai_auth = true`,
|
||||
contextWindow: 200000,
|
||||
},
|
||||
]),
|
||||
codexChatReasoning: {
|
||||
supportsThinking: true,
|
||||
supportsEffort: false,
|
||||
thinkingParam: "reasoning_split",
|
||||
effortParam: "none",
|
||||
outputFormat: "reasoning_details",
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "minimax_en",
|
||||
@@ -570,6 +633,13 @@ requires_openai_auth = true`,
|
||||
contextWindow: 1048576,
|
||||
},
|
||||
]),
|
||||
codexChatReasoning: {
|
||||
supportsThinking: true,
|
||||
supportsEffort: false,
|
||||
thinkingParam: "thinking",
|
||||
effortParam: "none",
|
||||
outputFormat: "reasoning_content",
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "xiaomimimo",
|
||||
iconColor: "#000000",
|
||||
@@ -593,6 +663,13 @@ requires_openai_auth = true`,
|
||||
contextWindow: 1048576,
|
||||
},
|
||||
]),
|
||||
codexChatReasoning: {
|
||||
supportsThinking: true,
|
||||
supportsEffort: false,
|
||||
thinkingParam: "thinking",
|
||||
effortParam: "none",
|
||||
outputFormat: "reasoning_content",
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "xiaomimimo",
|
||||
iconColor: "#000000",
|
||||
@@ -662,6 +739,13 @@ requires_openai_auth = true`,
|
||||
modelCatalog: modelCatalog([
|
||||
{ model: "zai-org/glm-5", displayName: "GLM-5", contextWindow: 202800 },
|
||||
]),
|
||||
codexChatReasoning: {
|
||||
supportsThinking: true,
|
||||
supportsEffort: false,
|
||||
thinkingParam: "thinking",
|
||||
effortParam: "none",
|
||||
outputFormat: "reasoning_content",
|
||||
},
|
||||
category: "aggregator",
|
||||
icon: "novita",
|
||||
iconColor: "#000000",
|
||||
@@ -685,6 +769,13 @@ requires_openai_auth = true`,
|
||||
contextWindow: 262144,
|
||||
},
|
||||
]),
|
||||
codexChatReasoning: {
|
||||
supportsThinking: true,
|
||||
supportsEffort: false,
|
||||
thinkingParam: "thinking",
|
||||
effortParam: "none",
|
||||
outputFormat: "reasoning_content",
|
||||
},
|
||||
category: "aggregator",
|
||||
icon: "nvidia",
|
||||
iconColor: "#000000",
|
||||
|
||||
@@ -1103,7 +1103,13 @@
|
||||
"catalogModelPlaceholder": "e.g., deepseek-v4-flash",
|
||||
"catalogColumnDisplay": "Menu Display Name",
|
||||
"catalogColumnModel": "Actual Request Model",
|
||||
"catalogColumnContext": "Context Window"
|
||||
"catalogColumnContext": "Context Window",
|
||||
"reasoningModeToggle": "Supports Thinking Mode",
|
||||
"reasoningModeHint": "Enable when the upstream Chat Completions API supports toggling thinking on/off. Providers like Kimi, GLM and Qwen usually fall into this category.",
|
||||
"reasoningEffortToggle": "Supports Reasoning Effort",
|
||||
"reasoningEffortHint": "Enable when the upstream supports thinking-depth control such as low/high/max. Enabling this also turns on thinking mode and converts Codex's reasoning.effort into the upstream Chat parameter.",
|
||||
"reasoningSectionToggle": "Reasoning Capability (Advanced · usually auto-detected)",
|
||||
"reasoningSectionHint": "Preset providers are configured automatically; custom providers are inferred from name/URL. Expand to override manually only when auto-detection is wrong."
|
||||
},
|
||||
"geminiConfig": {
|
||||
"envFile": "Environment Variables (.env)",
|
||||
|
||||
@@ -1103,7 +1103,13 @@
|
||||
"catalogModelPlaceholder": "例: deepseek-v4-flash",
|
||||
"catalogColumnDisplay": "メニュー表示名",
|
||||
"catalogColumnModel": "実際のリクエストモデル",
|
||||
"catalogColumnContext": "コンテキストウィンドウ"
|
||||
"catalogColumnContext": "コンテキストウィンドウ",
|
||||
"reasoningModeToggle": "思考モードに対応",
|
||||
"reasoningModeHint": "上流の Chat Completions API が思考(thinking)のオン/オフ切り替えに対応している場合に有効化します。Kimi、GLM、Qwen などが通常このタイプに該当します。",
|
||||
"reasoningEffortToggle": "思考レベルに対応",
|
||||
"reasoningEffortHint": "上流が low/high/max などの思考の深さの制御に対応している場合に有効化します。有効にすると思考モードも自動的にオンになり、Codex の reasoning.effort を上流の Chat パラメータに変換します。",
|
||||
"reasoningSectionToggle": "思考能力(詳細・通常は自動識別)",
|
||||
"reasoningSectionHint": "プリセットの供給元は自動的に設定され、カスタム供給元は名前/URL から自動推論されます。自動識別が正しくない場合のみ展開して手動で上書きしてください。"
|
||||
},
|
||||
"geminiConfig": {
|
||||
"envFile": "環境変数 (.env)",
|
||||
|
||||
@@ -1103,7 +1103,13 @@
|
||||
"catalogModelPlaceholder": "例如: deepseek-v4-flash",
|
||||
"catalogColumnDisplay": "菜单显示名",
|
||||
"catalogColumnModel": "实际请求模型",
|
||||
"catalogColumnContext": "上下文窗口"
|
||||
"catalogColumnContext": "上下文窗口",
|
||||
"reasoningModeToggle": "支持思考模式",
|
||||
"reasoningModeHint": "上游 Chat Completions 接口支持开启或关闭 thinking 时启用。Kimi、GLM、Qwen 等通常属于这一类。",
|
||||
"reasoningEffortToggle": "支持思考等级",
|
||||
"reasoningEffortHint": "上游支持 low/high/max 等思考深度控制时启用。启用后会自动启用思考模式,并把 Codex 的 reasoning.effort 转成上游 Chat 参数。",
|
||||
"reasoningSectionToggle": "思考能力(高级·通常自动识别)",
|
||||
"reasoningSectionHint": "预设供应商已自动配置;自定义供应商会按名称/地址自动推断。仅当自动识别不准时才需展开手动覆盖。"
|
||||
},
|
||||
"geminiConfig": {
|
||||
"envFile": "环境变量 (.env)",
|
||||
|
||||
@@ -137,6 +137,42 @@ export interface ClaudeDesktopModelRoute {
|
||||
supports1m?: boolean;
|
||||
}
|
||||
|
||||
export type CodexChatThinkingParam =
|
||||
| "none"
|
||||
| "thinking"
|
||||
| "enable_thinking"
|
||||
| "reasoning_split";
|
||||
|
||||
export type CodexChatEffortParam =
|
||||
| "none"
|
||||
| "reasoning_effort"
|
||||
// OpenRouter 原生归一化对象 reasoning:{effort}(区别于顶层 OpenAI 别名 reasoning_effort)
|
||||
| "reasoning.effort";
|
||||
|
||||
export type CodexChatEffortValueMode =
|
||||
| "passthrough"
|
||||
| "low_high"
|
||||
| "deepseek"
|
||||
// OpenRouter effort 枚举 xhigh|high|medium|low|minimal(无 max,max 钳到 xhigh)
|
||||
| "openrouter";
|
||||
|
||||
export type CodexChatReasoningOutputFormat =
|
||||
| "auto"
|
||||
| "reasoning_content"
|
||||
| "reasoning"
|
||||
| "reasoning_details"
|
||||
| "think_tags";
|
||||
|
||||
export interface CodexChatReasoning {
|
||||
supportsThinking?: boolean;
|
||||
supportsEffort?: boolean;
|
||||
thinkingParam?: CodexChatThinkingParam;
|
||||
effortParam?: CodexChatEffortParam;
|
||||
effortValueMode?: CodexChatEffortValueMode;
|
||||
// 声明性字段:标注上游 reasoning 回传位置。当前提取靠穷举字段,未读取此值(think_tags 尚未接线)。
|
||||
outputFormat?: CodexChatReasoningOutputFormat;
|
||||
}
|
||||
|
||||
// 供应商元数据(字段名与后端一致,保持 snake_case)
|
||||
export interface ProviderMeta {
|
||||
// 自定义端点:以 URL 为键,值为端点信息
|
||||
@@ -181,6 +217,8 @@ export interface ProviderMeta {
|
||||
promptCacheKey?: string;
|
||||
// Codex OAuth FAST mode: injects service_tier="priority" on ChatGPT Codex requests
|
||||
codexFastMode?: boolean;
|
||||
// Codex Responses -> Chat Completions reasoning capability metadata
|
||||
codexChatReasoning?: CodexChatReasoning;
|
||||
// 供应商类型(用于识别 Copilot 等特殊供应商)
|
||||
providerType?: string;
|
||||
// GitHub Copilot 关联账号 ID(旧字段,保留兼容读取)
|
||||
|
||||
Reference in New Issue
Block a user