mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 16:56:16 +08:00
af58740bcd
ChatGPT's Codex backend routes model availability by the originator and version header pair. Requests identifying as cc-switch without a version were assigned to a cohort where gpt-5.6-luna resolved to an unavailable internal engine, resulting in a misleading 404 Model not found response. Identify takeover requests as codex_cli_rs and send version 0.144.1, satisfying luna's minimal_client_version requirement of 0.144.0. A direct HTTP A/B test confirmed the existing headers returned 404 while the aligned identity completed successfully, so no WebSocket transport workaround is required.
2563 lines
90 KiB
Rust
2563 lines
90 KiB
Rust
//! Claude (Anthropic) Provider Adapter
|
||
//!
|
||
//! 支持透传模式和 OpenAI 格式转换模式
|
||
//!
|
||
//! ## API 格式
|
||
//! - **anthropic** (默认): Anthropic Messages API 格式,直接透传
|
||
//! - **openai_chat**: OpenAI Chat Completions 格式,需要 Anthropic ↔ OpenAI 转换
|
||
//! - **openai_responses**: OpenAI Responses API 格式,需要 Anthropic ↔ Responses 转换
|
||
//! - **gemini_native**: Google Gemini Native generateContent 格式,需要 Anthropic ↔ Gemini 转换
|
||
//!
|
||
//! ## 认证模式
|
||
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
|
||
//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key)
|
||
//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传
|
||
//! - **GitHubCopilot**: GitHub Copilot (OAuth + Copilot Token)
|
||
|
||
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
||
use crate::provider::Provider;
|
||
use crate::proxy::error::ProxyError;
|
||
use serde_json::{json, Value};
|
||
|
||
const ANTHROPIC_THINKING_PLACEHOLDER: &str = "tool call";
|
||
const ANTHROPIC_REDACTED_THINKING_PLACEHOLDER: &str = "[redacted thinking]";
|
||
// Keep hints lowercase; matching lowercases only the input value.
|
||
const REASONING_VENDOR_HINTS: &[&str] = &["moonshot", "kimi", "deepseek", "mimo", "xiaomimimo"];
|
||
|
||
// ChatGPT Codex 后端按 originator+version 组合做模型 cohort 路由:非官方身份会把
|
||
// gpt-5.6-luna 解析到未部署的内部引擎(HTTP 404 Model not found,openai/codex#31967,
|
||
// 本机 A/B 实测确认)。两个头必须成对发送,缺一即 404;version 需 ≥ 目标模型
|
||
// catalog 的 minimal_client_version(luna=0.144.0),新模型抬门槛时同步 bump。
|
||
const CODEX_OAUTH_ORIGINATOR: &str = "codex_cli_rs";
|
||
const CODEX_OAUTH_CLIENT_VERSION: &str = "0.144.1";
|
||
|
||
/// 获取 Claude 供应商的 API 格式
|
||
///
|
||
/// 供 handler/forwarder 外部使用的公开函数。
|
||
/// 优先级:meta.apiFormat > settings_config.api_format > openrouter_compat_mode > 默认 "anthropic"
|
||
pub fn get_claude_api_format(provider: &Provider) -> &'static str {
|
||
// 0) Codex OAuth 强制使用 openai_responses(不可被覆盖)
|
||
if let Some(meta) = provider.meta.as_ref() {
|
||
if meta.provider_type.as_deref() == Some("codex_oauth") {
|
||
return "openai_responses";
|
||
}
|
||
}
|
||
|
||
// 1) Preferred: meta.apiFormat (SSOT, never written to Claude Code config)
|
||
if let Some(meta) = provider.meta.as_ref() {
|
||
if let Some(api_format) = meta.api_format.as_deref() {
|
||
return match api_format {
|
||
"openai_chat" => "openai_chat",
|
||
"openai_responses" => "openai_responses",
|
||
"gemini_native" => "gemini_native",
|
||
_ => "anthropic",
|
||
};
|
||
}
|
||
}
|
||
|
||
// 2) Backward compatibility: legacy settings_config.api_format
|
||
if let Some(api_format) = provider
|
||
.settings_config
|
||
.get("api_format")
|
||
.and_then(|v| v.as_str())
|
||
{
|
||
return match api_format {
|
||
"openai_chat" => "openai_chat",
|
||
"openai_responses" => "openai_responses",
|
||
"gemini_native" => "gemini_native",
|
||
_ => "anthropic",
|
||
};
|
||
}
|
||
|
||
// 3) Backward compatibility: legacy openrouter_compat_mode (bool/number/string)
|
||
let raw = provider.settings_config.get("openrouter_compat_mode");
|
||
let enabled = match raw {
|
||
Some(serde_json::Value::Bool(v)) => *v,
|
||
Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0,
|
||
Some(serde_json::Value::String(value)) => {
|
||
let normalized = value.trim().to_lowercase();
|
||
normalized == "true" || normalized == "1"
|
||
}
|
||
_ => false,
|
||
};
|
||
|
||
if enabled {
|
||
"openai_chat"
|
||
} else {
|
||
"anthropic"
|
||
}
|
||
}
|
||
|
||
pub fn claude_api_format_needs_transform(api_format: &str) -> bool {
|
||
matches!(
|
||
api_format,
|
||
"openai_chat" | "openai_responses" | "gemini_native"
|
||
)
|
||
}
|
||
|
||
fn is_reasoning_vendor_identifier(value: &str) -> bool {
|
||
let value = value.to_ascii_lowercase();
|
||
REASONING_VENDOR_HINTS
|
||
.iter()
|
||
.any(|hint| value.contains(hint))
|
||
}
|
||
|
||
fn should_normalize_anthropic_tool_thinking_history(
|
||
provider: &Provider,
|
||
body: &Value,
|
||
api_format: &str,
|
||
) -> bool {
|
||
if api_format.trim() != "anthropic" {
|
||
return false;
|
||
}
|
||
|
||
if body
|
||
.get("model")
|
||
.and_then(|m| m.as_str())
|
||
.is_some_and(is_reasoning_vendor_identifier)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
let settings = &provider.settings_config;
|
||
[
|
||
settings
|
||
.get("env")
|
||
.and_then(|env| env.get("ANTHROPIC_BASE_URL"))
|
||
.and_then(|v| v.as_str()),
|
||
settings.get("base_url").and_then(|v| v.as_str()),
|
||
settings.get("baseURL").and_then(|v| v.as_str()),
|
||
settings.get("apiEndpoint").and_then(|v| v.as_str()),
|
||
]
|
||
.into_iter()
|
||
.flatten()
|
||
.any(is_reasoning_vendor_identifier)
|
||
}
|
||
|
||
/// DeepSeek's Anthropic-compatible endpoint requires thinking history to be
|
||
/// replayed on every assistant turn that contains tool_use. Some Anthropic SDK
|
||
/// clients keep the tool history but drop or redact the thinking block, which
|
||
/// makes DeepSeek reject the next request with `content[].thinking ... must be
|
||
/// passed back`. Normalize only the narrow tool-call history shape for
|
||
/// providers known to require plain `thinking` blocks.
|
||
pub fn normalize_anthropic_tool_thinking_history_for_provider(
|
||
body: &mut Value,
|
||
provider: &Provider,
|
||
api_format: &str,
|
||
) -> bool {
|
||
if !should_normalize_anthropic_tool_thinking_history(provider, body, api_format) {
|
||
return false;
|
||
}
|
||
|
||
normalize_anthropic_tool_thinking_history(body)
|
||
}
|
||
|
||
/// DeepSeek official Anthropic-compatible endpoint URL
|
||
const DEEPSEEK_OFFICIAL_ANTHROPIC_URL: &str = "https://api.deepseek.com/anthropic";
|
||
|
||
/// Check whether the provider is configured to use DeepSeek's official
|
||
/// Anthropic-compatible endpoint.
|
||
fn is_deepseek_official_anthropic_endpoint(provider: &Provider) -> bool {
|
||
let settings = &provider.settings_config;
|
||
let base_url = settings
|
||
.get("env")
|
||
.and_then(|env| env.get("ANTHROPIC_BASE_URL"))
|
||
.and_then(|v| v.as_str())
|
||
.or_else(|| settings.get("base_url").and_then(|v| v.as_str()))
|
||
.or_else(|| settings.get("baseURL").and_then(|v| v.as_str()))
|
||
.or_else(|| settings.get("apiEndpoint").and_then(|v| v.as_str()));
|
||
|
||
base_url.map(|u| u.trim_end_matches('/')) == Some(DEEPSEEK_OFFICIAL_ANTHROPIC_URL)
|
||
}
|
||
|
||
/// DeepSeek's official Anthropic-compatible endpoint treats
|
||
/// `thinking: { type: "disabled" }` and effort parameters (`output_config.effort`
|
||
/// or `reasoning_effort`) as mutually exclusive, returning HTTP 400:
|
||
/// "thinking options type cannot be disabled when reasoning_effort is set".
|
||
/// This breaks Claude Code 2.1.166+ Workflow/Dynamic Workflow features.
|
||
///
|
||
/// Rather than overriding Claude Code's intentional `thinking: disabled` for
|
||
/// sub-agents, we respect that decision and remove the conflicting effort
|
||
/// parameters instead. `thinking: disabled` means "don't output thinking
|
||
/// blocks", which is the correct behavior for sub-agents that don't need
|
||
/// to display reasoning to the user.
|
||
///
|
||
/// <https://github.com/deepseek-ai/DeepSeek-V3/issues/1397>
|
||
pub fn normalize_deepseek_thinking_disabled_strip_effort(
|
||
body: &mut Value,
|
||
provider: &Provider,
|
||
) -> bool {
|
||
if !is_deepseek_official_anthropic_endpoint(provider) {
|
||
return false;
|
||
}
|
||
|
||
let thinking_type = body
|
||
.get("thinking")
|
||
.and_then(|t| t.get("type"))
|
||
.and_then(|t| t.as_str());
|
||
|
||
if thinking_type != Some("disabled") {
|
||
return false;
|
||
}
|
||
|
||
let mut changed = false;
|
||
|
||
// Remove output_config.effort (Anthropic format)
|
||
if let Some(oc) = body
|
||
.get_mut("output_config")
|
||
.and_then(|v| v.as_object_mut())
|
||
{
|
||
changed |= oc.remove("effort").is_some();
|
||
// Clean up empty output_config
|
||
if oc.is_empty() {
|
||
body.as_object_mut().unwrap().remove("output_config");
|
||
}
|
||
}
|
||
|
||
// Remove reasoning_effort (OpenAI format, may be present in passthrough)
|
||
if body.get("reasoning_effort").is_some() {
|
||
body.as_object_mut().unwrap().remove("reasoning_effort");
|
||
changed = true;
|
||
}
|
||
|
||
changed
|
||
}
|
||
|
||
pub fn normalize_anthropic_messages_for_provider(
|
||
body: &mut Value,
|
||
provider: &Provider,
|
||
api_format: &str,
|
||
) -> bool {
|
||
if api_format.trim() != "anthropic" {
|
||
return false;
|
||
}
|
||
|
||
let mut changed =
|
||
normalize_anthropic_tool_thinking_history_for_provider(body, provider, api_format);
|
||
changed |= normalize_deepseek_thinking_disabled_strip_effort(body, provider);
|
||
changed
|
||
}
|
||
|
||
fn normalize_anthropic_tool_thinking_history(body: &mut Value) -> bool {
|
||
let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
|
||
return false;
|
||
};
|
||
|
||
let mut changed = false;
|
||
for message in messages {
|
||
if message.get("role").and_then(Value::as_str) != Some("assistant") {
|
||
continue;
|
||
}
|
||
|
||
let Some(content) = message.get_mut("content").and_then(Value::as_array_mut) else {
|
||
continue;
|
||
};
|
||
if !content
|
||
.iter()
|
||
.any(|block| block.get("type").and_then(Value::as_str) == Some("tool_use"))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
let mut has_thinking = false;
|
||
for block in content.iter_mut() {
|
||
match block.get("type").and_then(Value::as_str) {
|
||
Some("thinking") => {
|
||
let has_non_empty_thinking = block
|
||
.get("thinking")
|
||
.and_then(Value::as_str)
|
||
.is_some_and(|text| !text.trim().is_empty());
|
||
if let Some(obj) = block.as_object_mut() {
|
||
if obj.remove("signature").is_some() {
|
||
changed = true;
|
||
}
|
||
if !has_non_empty_thinking {
|
||
obj.insert(
|
||
"thinking".to_string(),
|
||
json!(ANTHROPIC_THINKING_PLACEHOLDER),
|
||
);
|
||
changed = true;
|
||
}
|
||
}
|
||
has_thinking = true;
|
||
}
|
||
Some("redacted_thinking") => {
|
||
*block = json!({
|
||
"type": "thinking",
|
||
"thinking": ANTHROPIC_REDACTED_THINKING_PLACEHOLDER
|
||
});
|
||
has_thinking = true;
|
||
changed = true;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
if !has_thinking {
|
||
content.insert(
|
||
0,
|
||
json!({
|
||
"type": "thinking",
|
||
"thinking": ANTHROPIC_THINKING_PLACEHOLDER
|
||
}),
|
||
);
|
||
changed = true;
|
||
}
|
||
}
|
||
|
||
changed
|
||
}
|
||
|
||
fn should_preserve_reasoning_content_for_openai_chat(provider: &Provider, body: &Value) -> bool {
|
||
if body
|
||
.get("model")
|
||
.and_then(|m| m.as_str())
|
||
.is_some_and(is_reasoning_vendor_identifier)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
let settings = &provider.settings_config;
|
||
let base_urls = [
|
||
settings
|
||
.get("env")
|
||
.and_then(|env| env.get("ANTHROPIC_BASE_URL"))
|
||
.and_then(|v| v.as_str()),
|
||
settings.get("base_url").and_then(|v| v.as_str()),
|
||
settings.get("baseURL").and_then(|v| v.as_str()),
|
||
settings.get("apiEndpoint").and_then(|v| v.as_str()),
|
||
];
|
||
|
||
base_urls
|
||
.into_iter()
|
||
.flatten()
|
||
.any(is_reasoning_vendor_identifier)
|
||
}
|
||
|
||
pub fn transform_claude_request_for_api_format(
|
||
body: serde_json::Value,
|
||
provider: &Provider,
|
||
api_format: &str,
|
||
session_id: Option<&str>,
|
||
shadow_store: Option<&super::gemini_shadow::GeminiShadowStore>,
|
||
) -> Result<serde_json::Value, ProxyError> {
|
||
let is_codex_oauth = provider.is_codex_oauth();
|
||
|
||
// Copilot 场景:优先从 metadata.user_id 提取 session ID 作为 cache key
|
||
// 格式: "uuid_sessionId" → 提取 "_" 后面的部分作为 session 标识
|
||
// 同一会话的请求共享 cache key,提升 Copilot 缓存命中率
|
||
let is_copilot = provider
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|m| m.provider_type.as_deref())
|
||
== Some("github_copilot")
|
||
|| provider
|
||
.settings_config
|
||
.get("baseUrl")
|
||
.and_then(|v| v.as_str())
|
||
.is_some_and(|u| u.contains("githubcopilot.com"));
|
||
let session_cache_key: Option<String> = if is_copilot {
|
||
let metadata = body.get("metadata");
|
||
// Session 提取优先级(与 forwarder 和 session.rs 统一):
|
||
// 1. metadata.user_id 中的 _session_ 后缀
|
||
// 2. metadata.session_id(直接字段)
|
||
metadata
|
||
.and_then(|m| m.get("user_id"))
|
||
.and_then(|v| v.as_str())
|
||
.and_then(super::super::session::parse_session_from_user_id)
|
||
.or_else(|| {
|
||
metadata
|
||
.and_then(|m| m.get("session_id"))
|
||
.and_then(|v| v.as_str())
|
||
.filter(|s| !s.is_empty())
|
||
.map(|s| s.to_string())
|
||
})
|
||
} else {
|
||
session_id
|
||
.map(str::trim)
|
||
.filter(|s| !s.is_empty())
|
||
.map(ToString::to_string)
|
||
};
|
||
|
||
let explicit_cache_key = provider
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|m| m.prompt_cache_key.as_deref());
|
||
let (cache_key, cache_key_source) = if let Some(key) = explicit_cache_key {
|
||
(Some(key), "explicit")
|
||
} else if let Some(key) = session_cache_key.as_deref() {
|
||
(Some(key), "session")
|
||
} else {
|
||
(None, "none")
|
||
};
|
||
match api_format {
|
||
"openai_responses" => {
|
||
log::debug!(
|
||
"[Cache] OpenAI Responses prompt_cache_key source={cache_key_source}, provider={}, codex_oauth={is_codex_oauth}, has_key={}",
|
||
provider.id,
|
||
cache_key.is_some()
|
||
);
|
||
// Codex OAuth (ChatGPT Plus/Pro 反代) 需要在请求体里强制 store: false
|
||
// + include: ["reasoning.encrypted_content"],由 transform 层统一处理。
|
||
let codex_fast_mode = provider.codex_fast_mode_enabled();
|
||
super::transform_responses::anthropic_to_responses(
|
||
body,
|
||
cache_key,
|
||
is_codex_oauth,
|
||
codex_fast_mode,
|
||
)
|
||
}
|
||
"openai_chat" => {
|
||
let preserve_reasoning_content =
|
||
should_preserve_reasoning_content_for_openai_chat(provider, &body);
|
||
let mut result = super::transform::anthropic_to_openai_with_reasoning_content(
|
||
body,
|
||
preserve_reasoning_content,
|
||
)?;
|
||
// Inject prompt_cache_key only if explicitly configured in meta
|
||
if let Some(key) = provider
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|m| m.prompt_cache_key.as_deref())
|
||
{
|
||
result["prompt_cache_key"] = serde_json::json!(key);
|
||
}
|
||
// 流式请求必须注入 stream_options.include_usage,否则 OpenAI 兼容上游
|
||
// 不在 SSE 末尾吐 usage → 转换出的 Anthropic message_delta 全 0 →
|
||
// 整笔 input/output/cache 漏记(与 Codex Responses→Chat 路径同源)。
|
||
super::transform::inject_openai_stream_include_usage(&mut result);
|
||
Ok(result)
|
||
}
|
||
"gemini_native" => super::transform_gemini::anthropic_to_gemini_with_shadow(
|
||
body,
|
||
shadow_store,
|
||
Some(&provider.id),
|
||
session_id,
|
||
),
|
||
_ => Ok(body),
|
||
}
|
||
}
|
||
|
||
/// Claude 适配器
|
||
pub struct ClaudeAdapter;
|
||
|
||
impl ClaudeAdapter {
|
||
pub fn new() -> Self {
|
||
Self
|
||
}
|
||
|
||
/// 获取供应商类型
|
||
///
|
||
/// 根据 base_url 和 auth_mode 检测具体的供应商类型:
|
||
/// - GitHubCopilot: meta.provider_type 为 github_copilot 或 base_url 包含 githubcopilot.com
|
||
/// - CodexOAuth: meta.provider_type 为 codex_oauth
|
||
/// - OpenRouter: base_url 包含 openrouter.ai
|
||
/// - ClaudeAuth: auth_mode 为 bearer_only
|
||
/// - Claude: 默认 Anthropic 官方
|
||
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
|
||
// 检测 Gemini Native 格式
|
||
if self.get_api_format(provider) == "gemini_native" {
|
||
return match self.extract_key(provider) {
|
||
Some(key) if key.starts_with("ya29.") || key.starts_with('{') => {
|
||
ProviderType::GeminiCli
|
||
}
|
||
_ => ProviderType::Gemini,
|
||
};
|
||
}
|
||
|
||
// 检测 Codex OAuth (ChatGPT Plus/Pro)
|
||
if self.is_codex_oauth(provider) {
|
||
return ProviderType::CodexOAuth;
|
||
}
|
||
|
||
// 检测 GitHub Copilot
|
||
if self.is_github_copilot(provider) {
|
||
return ProviderType::GitHubCopilot;
|
||
}
|
||
|
||
// 检测 OpenRouter
|
||
if self.is_openrouter(provider) {
|
||
return ProviderType::OpenRouter;
|
||
}
|
||
|
||
// 检测 ClaudeAuth (仅 Bearer 认证)
|
||
if self.is_bearer_only_mode(provider) {
|
||
return ProviderType::ClaudeAuth;
|
||
}
|
||
|
||
ProviderType::Claude
|
||
}
|
||
|
||
/// 检测是否为 Codex OAuth 供应商(ChatGPT Plus/Pro 反代)
|
||
fn is_codex_oauth(&self, provider: &Provider) -> bool {
|
||
if let Some(meta) = provider.meta.as_ref() {
|
||
if meta.provider_type.as_deref() == Some("codex_oauth") {
|
||
return true;
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
/// 检测是否为 GitHub Copilot 供应商
|
||
fn is_github_copilot(&self, provider: &Provider) -> bool {
|
||
// 方式1: 检查 meta.provider_type
|
||
if let Some(meta) = provider.meta.as_ref() {
|
||
if meta.provider_type.as_deref() == Some("github_copilot") {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// 方式2: 检查 base_url(兼容旧数据的 fallback,后续应优先依赖 providerType)
|
||
if let Ok(base_url) = self.extract_base_url(provider) {
|
||
if base_url.contains("githubcopilot.com") {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
false
|
||
}
|
||
|
||
/// 检测是否使用 OpenRouter
|
||
fn is_openrouter(&self, provider: &Provider) -> bool {
|
||
if let Ok(base_url) = self.extract_base_url(provider) {
|
||
return base_url.contains("openrouter.ai");
|
||
}
|
||
false
|
||
}
|
||
|
||
/// 获取 API 格式
|
||
///
|
||
/// 从 provider.meta.api_format 读取格式设置:
|
||
/// - "anthropic" (默认): Anthropic Messages API 格式,直接透传
|
||
/// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||
/// - "openai_responses": OpenAI Responses API 格式,需要格式转换
|
||
fn get_api_format(&self, provider: &Provider) -> &'static str {
|
||
get_claude_api_format(provider)
|
||
}
|
||
|
||
/// 检测是否为仅 Bearer 认证模式
|
||
fn is_bearer_only_mode(&self, provider: &Provider) -> bool {
|
||
// 检查 settings_config 中的 auth_mode
|
||
if let Some(auth_mode) = provider
|
||
.settings_config
|
||
.get("auth_mode")
|
||
.and_then(|v| v.as_str())
|
||
{
|
||
if auth_mode == "bearer_only" {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// 检查 env 中的 AUTH_MODE
|
||
if let Some(env) = provider.settings_config.get("env") {
|
||
if let Some(auth_mode) = env.get("AUTH_MODE").and_then(|v| v.as_str()) {
|
||
if auth_mode == "bearer_only" {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
|
||
false
|
||
}
|
||
|
||
/// 从 Provider 配置中提取 API Key
|
||
fn extract_key(&self, provider: &Provider) -> Option<String> {
|
||
if let Some(env) = provider.settings_config.get("env") {
|
||
// Anthropic 标准 key
|
||
if let Some(key) = env
|
||
.get("ANTHROPIC_AUTH_TOKEN")
|
||
.and_then(|v| v.as_str())
|
||
.map(str::trim)
|
||
.filter(|s| !s.is_empty())
|
||
{
|
||
log::debug!("[Claude] 使用 ANTHROPIC_AUTH_TOKEN");
|
||
return Some(key.to_string());
|
||
}
|
||
if let Some(key) = env
|
||
.get("ANTHROPIC_API_KEY")
|
||
.and_then(|v| v.as_str())
|
||
.map(str::trim)
|
||
.filter(|s| !s.is_empty())
|
||
{
|
||
log::debug!("[Claude] 使用 ANTHROPIC_API_KEY");
|
||
return Some(key.to_string());
|
||
}
|
||
// OpenRouter key
|
||
if let Some(key) = env
|
||
.get("OPENROUTER_API_KEY")
|
||
.and_then(|v| v.as_str())
|
||
.map(str::trim)
|
||
.filter(|s| !s.is_empty())
|
||
{
|
||
log::debug!("[Claude] 使用 OPENROUTER_API_KEY");
|
||
return Some(key.to_string());
|
||
}
|
||
// 备选 OpenAI key (用于 OpenRouter)
|
||
if let Some(key) = env
|
||
.get("OPENAI_API_KEY")
|
||
.and_then(|v| v.as_str())
|
||
.map(str::trim)
|
||
.filter(|s| !s.is_empty())
|
||
{
|
||
log::debug!("[Claude] 使用 OPENAI_API_KEY");
|
||
return Some(key.to_string());
|
||
}
|
||
// Gemini Native key
|
||
if let Some(key) = env
|
||
.get("GEMINI_API_KEY")
|
||
.and_then(|v| v.as_str())
|
||
.map(str::trim)
|
||
.filter(|s| !s.is_empty())
|
||
{
|
||
log::debug!("[Claude] 使用 GEMINI_API_KEY");
|
||
return Some(key.to_string());
|
||
}
|
||
}
|
||
|
||
// 尝试直接获取
|
||
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(|s| !s.is_empty())
|
||
{
|
||
log::debug!("[Claude] 使用 apiKey/api_key");
|
||
return Some(key.to_string());
|
||
}
|
||
|
||
log::warn!("[Claude] 未找到有效的 API Key");
|
||
None
|
||
}
|
||
|
||
/// 根据 env 中填写的变量名推断 Anthropic 默认走哪种鉴权策略。
|
||
///
|
||
/// 与 Anthropic SDK 原生语义保持一致:
|
||
/// - `ANTHROPIC_AUTH_TOKEN` → `ClaudeAuth`(发送 `Authorization: Bearer`)
|
||
/// - `ANTHROPIC_API_KEY` → `Anthropic` (发送 `x-api-key`)
|
||
///
|
||
/// 优先级与 [`extract_key`] 一致;两者都缺时返回 `None` 由调用方决定 fallback。
|
||
fn infer_anthropic_auth_strategy(&self, provider: &Provider) -> Option<AuthStrategy> {
|
||
let env = provider.settings_config.get("env")?;
|
||
|
||
let has_value = |key: &str| -> bool {
|
||
env.get(key)
|
||
.and_then(|v| v.as_str())
|
||
.map(str::trim)
|
||
.filter(|s| !s.is_empty())
|
||
.is_some()
|
||
};
|
||
|
||
if has_value("ANTHROPIC_AUTH_TOKEN") {
|
||
return Some(AuthStrategy::ClaudeAuth);
|
||
}
|
||
if has_value("ANTHROPIC_API_KEY") {
|
||
return Some(AuthStrategy::Anthropic);
|
||
}
|
||
None
|
||
}
|
||
}
|
||
|
||
impl Default for ClaudeAdapter {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|
||
|
||
impl ProviderAdapter for ClaudeAdapter {
|
||
fn name(&self) -> &'static str {
|
||
"Claude"
|
||
}
|
||
|
||
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
|
||
// Codex OAuth: 强制使用 ChatGPT 后端 API 端点(忽略用户配置的 base_url)
|
||
if self.is_codex_oauth(provider) {
|
||
return Ok(super::CHATGPT_CODEX_BASE_URL.to_string());
|
||
}
|
||
|
||
// 1. 从 env 中获取
|
||
if let Some(env) = provider.settings_config.get("env") {
|
||
if let Some(url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
|
||
return Ok(url.trim_end_matches('/').to_string());
|
||
}
|
||
}
|
||
|
||
// 2. 尝试直接获取
|
||
if let Some(url) = provider
|
||
.settings_config
|
||
.get("base_url")
|
||
.and_then(|v| v.as_str())
|
||
{
|
||
return Ok(url.trim_end_matches('/').to_string());
|
||
}
|
||
|
||
if let Some(url) = provider
|
||
.settings_config
|
||
.get("baseURL")
|
||
.and_then(|v| v.as_str())
|
||
{
|
||
return Ok(url.trim_end_matches('/').to_string());
|
||
}
|
||
|
||
if let Some(url) = provider
|
||
.settings_config
|
||
.get("apiEndpoint")
|
||
.and_then(|v| v.as_str())
|
||
{
|
||
return Ok(url.trim_end_matches('/').to_string());
|
||
}
|
||
|
||
Err(ProxyError::ConfigError(
|
||
"Claude Provider 缺少 base_url 配置".to_string(),
|
||
))
|
||
}
|
||
|
||
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
|
||
let provider_type = self.provider_type(provider);
|
||
|
||
// GitHub Copilot 使用特殊的认证策略
|
||
// 实际的 token 会在代理请求时动态获取
|
||
if provider_type == ProviderType::GitHubCopilot {
|
||
// 返回一个占位符,实际 token 由 CopilotAuthManager 动态提供
|
||
return Some(AuthInfo::new(
|
||
"copilot_placeholder".to_string(),
|
||
AuthStrategy::GitHubCopilot,
|
||
));
|
||
}
|
||
|
||
// Codex OAuth (ChatGPT Plus/Pro) 同样使用占位符
|
||
// 实际的 access_token 由 CodexOAuthManager 动态提供
|
||
if provider_type == ProviderType::CodexOAuth {
|
||
return Some(AuthInfo::new(
|
||
"codex_oauth_placeholder".to_string(),
|
||
AuthStrategy::CodexOAuth,
|
||
));
|
||
}
|
||
|
||
let key = self.extract_key(provider)?;
|
||
|
||
match provider_type {
|
||
ProviderType::GeminiCli => {
|
||
// Parse stored OAuth JSON and only attach access_token when
|
||
// it's actually usable. `parse_oauth_credentials` accepts
|
||
// refresh-token-only JSON (which is legitimate before the
|
||
// first refresh) and also surfaces `{"access_token": "", ...}`
|
||
// for expired credentials. In both cases we would otherwise
|
||
// send `Authorization: Bearer ` to upstream and get a 401.
|
||
//
|
||
// CC Switch does not currently exchange the refresh_token for
|
||
// a fresh access_token. Until that path exists, degrade to
|
||
// plain GoogleOAuth strategy (which still sends the raw key
|
||
// as a fallback) and log loudly so users know to refresh
|
||
// their `~/.gemini/oauth_creds.json`.
|
||
match super::gemini::GeminiAdapter::new().parse_oauth_credentials(&key) {
|
||
Some(creds) if !creds.access_token.is_empty() => {
|
||
Some(AuthInfo::with_access_token(key, creds.access_token))
|
||
}
|
||
Some(_) => {
|
||
log::warn!(
|
||
"[Gemini OAuth] access_token missing or empty for provider `{}`; \
|
||
bearer auth will likely fail with 401. Refresh \
|
||
~/.gemini/oauth_creds.json via the gemini CLI to obtain a new token.",
|
||
provider.id
|
||
);
|
||
Some(AuthInfo::new(key, AuthStrategy::GoogleOAuth))
|
||
}
|
||
None => Some(AuthInfo::new(key, AuthStrategy::GoogleOAuth)),
|
||
}
|
||
}
|
||
ProviderType::Gemini => Some(AuthInfo::new(key, AuthStrategy::Google)),
|
||
ProviderType::OpenRouter => Some(AuthInfo::new(key, AuthStrategy::Bearer)),
|
||
ProviderType::ClaudeAuth => Some(AuthInfo::new(key, AuthStrategy::ClaudeAuth)),
|
||
_ => {
|
||
// 按 env 中的变量名推断鉴权策略,对齐 Anthropic SDK 语义:
|
||
// ANTHROPIC_AUTH_TOKEN → Authorization: Bearer
|
||
// ANTHROPIC_API_KEY → x-api-key
|
||
// 其他来源(apiKey 直填等)默认走 x-api-key(Anthropic 官方协议)。
|
||
let strategy = self
|
||
.infer_anthropic_auth_strategy(provider)
|
||
.unwrap_or(AuthStrategy::Anthropic);
|
||
Some(AuthInfo::new(key, strategy))
|
||
}
|
||
}
|
||
}
|
||
|
||
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||
// Codex OAuth: 所有请求统一走 /responses 端点
|
||
if base_url == super::CHATGPT_CODEX_BASE_URL {
|
||
let _ = endpoint; // 忽略原始 endpoint
|
||
return format!("{}/responses", super::CHATGPT_CODEX_BASE_URL);
|
||
}
|
||
|
||
// NOTE:
|
||
// 过去 OpenRouter 只有 OpenAI Chat Completions 兼容接口,需要把 Claude 的 `/v1/messages`
|
||
// 映射到 `/v1/chat/completions`,并做 Anthropic ↔ OpenAI 的格式转换。
|
||
//
|
||
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
|
||
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
|
||
//
|
||
let mut base = format!(
|
||
"{}/{}",
|
||
base_url.trim_end_matches('/'),
|
||
endpoint.trim_start_matches('/')
|
||
);
|
||
|
||
// 去除重复的 /v1/v1(可能由 base_url 与 endpoint 都带版本导致)
|
||
while base.contains("/v1/v1") {
|
||
base = base.replace("/v1/v1", "/v1");
|
||
}
|
||
|
||
base
|
||
}
|
||
|
||
fn get_auth_headers(
|
||
&self,
|
||
auth: &AuthInfo,
|
||
) -> Result<Vec<(http::HeaderName, http::HeaderValue)>, ProxyError> {
|
||
use super::adapter::auth_header_value as hv;
|
||
use http::{HeaderName, HeaderValue};
|
||
// 注意:anthropic-version 由 forwarder.rs 统一处理(透传客户端值或设置默认值)
|
||
let bearer = format!("Bearer {}", auth.api_key);
|
||
Ok(match auth.strategy {
|
||
AuthStrategy::Anthropic => {
|
||
vec![(HeaderName::from_static("x-api-key"), hv(&auth.api_key)?)]
|
||
}
|
||
AuthStrategy::ClaudeAuth | AuthStrategy::Bearer => {
|
||
vec![(HeaderName::from_static("authorization"), hv(&bearer)?)]
|
||
}
|
||
AuthStrategy::Google => vec![(
|
||
HeaderName::from_static("x-goog-api-key"),
|
||
hv(&auth.api_key)?,
|
||
)],
|
||
AuthStrategy::GoogleOAuth => {
|
||
let token = auth.access_token.as_ref().unwrap_or(&auth.api_key);
|
||
vec![
|
||
(
|
||
HeaderName::from_static("authorization"),
|
||
hv(&format!("Bearer {token}"))?,
|
||
),
|
||
(
|
||
HeaderName::from_static("x-goog-api-client"),
|
||
HeaderValue::from_static("GeminiCLI/1.0"),
|
||
),
|
||
]
|
||
}
|
||
AuthStrategy::CodexOAuth => {
|
||
// 注意:bearer token 由 forwarder 动态注入到 auth.api_key
|
||
// ChatGPT-Account-Id 由 forwarder 注入额外 header
|
||
vec![
|
||
(HeaderName::from_static("authorization"), hv(&bearer)?),
|
||
(
|
||
HeaderName::from_static("originator"),
|
||
HeaderValue::from_static(CODEX_OAUTH_ORIGINATOR),
|
||
),
|
||
(
|
||
HeaderName::from_static("version"),
|
||
HeaderValue::from_static(CODEX_OAUTH_CLIENT_VERSION),
|
||
),
|
||
]
|
||
}
|
||
AuthStrategy::GitHubCopilot => {
|
||
// 生成请求追踪 ID
|
||
let request_id = uuid::Uuid::new_v4().to_string();
|
||
vec![
|
||
(HeaderName::from_static("authorization"), hv(&bearer)?),
|
||
(
|
||
HeaderName::from_static("editor-version"),
|
||
HeaderValue::from_static(super::copilot_auth::COPILOT_EDITOR_VERSION),
|
||
),
|
||
(
|
||
HeaderName::from_static("editor-plugin-version"),
|
||
HeaderValue::from_static(super::copilot_auth::COPILOT_PLUGIN_VERSION),
|
||
),
|
||
(
|
||
HeaderName::from_static("copilot-integration-id"),
|
||
HeaderValue::from_static(super::copilot_auth::COPILOT_INTEGRATION_ID),
|
||
),
|
||
(
|
||
HeaderName::from_static("user-agent"),
|
||
HeaderValue::from_static(super::copilot_auth::COPILOT_USER_AGENT),
|
||
),
|
||
(
|
||
HeaderName::from_static("x-github-api-version"),
|
||
HeaderValue::from_static(super::copilot_auth::COPILOT_API_VERSION),
|
||
),
|
||
// 26-04-01新增的copilot关键 headers
|
||
(
|
||
HeaderName::from_static("openai-intent"),
|
||
HeaderValue::from_static("conversation-agent"),
|
||
),
|
||
(
|
||
HeaderName::from_static("x-initiator"),
|
||
HeaderValue::from_static("user"),
|
||
),
|
||
(
|
||
HeaderName::from_static("x-interaction-type"),
|
||
HeaderValue::from_static("conversation-agent"),
|
||
),
|
||
// x-interaction-id 由 forwarder 按需注入(仅在有 session 时)
|
||
(
|
||
HeaderName::from_static("x-vscode-user-agent-library-version"),
|
||
HeaderValue::from_static("electron-fetch"),
|
||
),
|
||
(HeaderName::from_static("x-request-id"), hv(&request_id)?),
|
||
(HeaderName::from_static("x-agent-task-id"), hv(&request_id)?),
|
||
]
|
||
}
|
||
})
|
||
}
|
||
|
||
fn needs_transform(&self, provider: &Provider) -> bool {
|
||
// GitHub Copilot 总是需要格式转换 (Anthropic → OpenAI)
|
||
if self.is_github_copilot(provider) {
|
||
return true;
|
||
}
|
||
|
||
// Codex OAuth 总是需要格式转换 (Anthropic → OpenAI Responses API)
|
||
if self.is_codex_oauth(provider) {
|
||
return true;
|
||
}
|
||
|
||
// 根据 api_format 配置决定是否需要格式转换
|
||
// - "anthropic" (默认): 直接透传,无需转换
|
||
// - "openai_chat": 需要 Anthropic ↔ OpenAI Chat Completions 格式转换
|
||
// - "openai_responses": 需要 Anthropic ↔ OpenAI Responses API 格式转换
|
||
matches!(
|
||
self.get_api_format(provider),
|
||
"openai_chat" | "openai_responses" | "gemini_native"
|
||
)
|
||
}
|
||
|
||
fn transform_request(
|
||
&self,
|
||
body: serde_json::Value,
|
||
provider: &Provider,
|
||
) -> Result<serde_json::Value, ProxyError> {
|
||
transform_claude_request_for_api_format(
|
||
body,
|
||
provider,
|
||
self.get_api_format(provider),
|
||
None,
|
||
None,
|
||
)
|
||
}
|
||
|
||
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
|
||
// Heuristic: detect response format by presence of top-level fields.
|
||
// The ProviderAdapter trait's transform_response doesn't receive the Provider
|
||
// config, so we can't check api_format here. Instead we rely on the fact that
|
||
// Responses API always returns "output" while Chat Completions returns "choices".
|
||
// This is safe because the two formats are structurally disjoint.
|
||
if body.get("candidates").is_some() || body.get("promptFeedback").is_some() {
|
||
super::transform_gemini::gemini_to_anthropic(body)
|
||
} else if body.get("output").is_some() {
|
||
super::transform_responses::responses_to_anthropic(body)
|
||
} else {
|
||
super::transform::openai_to_anthropic(body)
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::provider::ProviderMeta;
|
||
use serde_json::json;
|
||
|
||
fn create_provider(config: serde_json::Value) -> Provider {
|
||
Provider {
|
||
id: "test".to_string(),
|
||
name: "Test Claude".to_string(),
|
||
settings_config: config,
|
||
website_url: None,
|
||
category: Some("claude".to_string()),
|
||
created_at: None,
|
||
sort_index: None,
|
||
notes: None,
|
||
meta: None,
|
||
icon: None,
|
||
icon_color: None,
|
||
in_failover_queue: false,
|
||
}
|
||
}
|
||
|
||
fn create_provider_with_meta(config: serde_json::Value, meta: ProviderMeta) -> Provider {
|
||
Provider {
|
||
id: "test".to_string(),
|
||
name: "Test Claude".to_string(),
|
||
settings_config: config,
|
||
website_url: None,
|
||
category: Some("claude".to_string()),
|
||
created_at: None,
|
||
sort_index: None,
|
||
notes: None,
|
||
meta: Some(meta),
|
||
icon: None,
|
||
icon_color: None,
|
||
in_failover_queue: false,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_base_url_from_env() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||
}
|
||
}));
|
||
|
||
let url = adapter.extract_base_url(&provider).unwrap();
|
||
assert_eq!(url, "https://api.anthropic.com");
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_auth_anthropic_auth_token_uses_claude_auth_strategy() {
|
||
// ANTHROPIC_AUTH_TOKEN 在 Anthropic SDK 里语义就是 Authorization: Bearer,
|
||
// 因此走 ClaudeAuth strategy 而不是 Anthropic(x-api-key)。
|
||
let adapter = ClaudeAdapter::new();
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||
"ANTHROPIC_AUTH_TOKEN": "sk-ant-test-key"
|
||
}
|
||
}));
|
||
|
||
let auth = adapter.extract_auth(&provider).unwrap();
|
||
assert_eq!(auth.api_key, "sk-ant-test-key");
|
||
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_auth_anthropic_api_key() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||
"ANTHROPIC_API_KEY": "sk-ant-test-key"
|
||
}
|
||
}));
|
||
|
||
let auth = adapter.extract_auth(&provider).unwrap();
|
||
assert_eq!(auth.api_key, "sk-ant-test-key");
|
||
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_auth_both_env_vars_prefer_auth_token() {
|
||
// 两个变量都填时,extract_key 选 AUTH_TOKEN,strategy 推断也必须保持一致。
|
||
let adapter = ClaudeAdapter::new();
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||
"ANTHROPIC_AUTH_TOKEN": "sk-from-auth-token",
|
||
"ANTHROPIC_API_KEY": "sk-from-api-key"
|
||
}
|
||
}));
|
||
|
||
let auth = adapter.extract_auth(&provider).unwrap();
|
||
assert_eq!(auth.api_key, "sk-from-auth-token");
|
||
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_auth_apikey_field_fallback_uses_anthropic_strategy() {
|
||
// 当用户没填任一 ANTHROPIC_* env,而是直接使用 apiKey 字段时,
|
||
// 视为没有显式语义偏好,默认走 Anthropic 官方协议(x-api-key)。
|
||
let adapter = ClaudeAdapter::new();
|
||
let provider = create_provider(json!({
|
||
"apiKey": "sk-direct",
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||
}
|
||
}));
|
||
|
||
let auth = adapter.extract_auth(&provider).unwrap();
|
||
assert_eq!(auth.api_key, "sk-direct");
|
||
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
|
||
}
|
||
|
||
#[test]
|
||
fn test_get_auth_headers_anthropic_emits_x_api_key() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let auth = AuthInfo::new("sk-ant-test".to_string(), AuthStrategy::Anthropic);
|
||
|
||
let headers = adapter.get_auth_headers(&auth).unwrap();
|
||
assert_eq!(headers.len(), 1);
|
||
assert_eq!(headers[0].0.as_str(), "x-api-key");
|
||
assert_eq!(headers[0].1.to_str().unwrap(), "sk-ant-test");
|
||
}
|
||
|
||
#[test]
|
||
fn test_get_auth_headers_claude_auth_emits_authorization_bearer() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let auth = AuthInfo::new("sk-relay-test".to_string(), AuthStrategy::ClaudeAuth);
|
||
|
||
let headers = adapter.get_auth_headers(&auth).unwrap();
|
||
assert_eq!(headers.len(), 1);
|
||
assert_eq!(headers[0].0.as_str(), "authorization");
|
||
assert_eq!(headers[0].1.to_str().unwrap(), "Bearer sk-relay-test");
|
||
}
|
||
|
||
#[test]
|
||
fn test_get_auth_headers_bearer_emits_authorization_bearer() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let auth = AuthInfo::new("sk-or-test".to_string(), AuthStrategy::Bearer);
|
||
|
||
let headers = adapter.get_auth_headers(&auth).unwrap();
|
||
assert_eq!(headers.len(), 1);
|
||
assert_eq!(headers[0].0.as_str(), "authorization");
|
||
assert_eq!(headers[0].1.to_str().unwrap(), "Bearer sk-or-test");
|
||
}
|
||
|
||
#[test]
|
||
fn test_get_auth_headers_rejects_illegal_header_chars() {
|
||
// 用户粘贴含 \r\n 的"脏"key 不能让进程 panic
|
||
let adapter = ClaudeAdapter::new();
|
||
let auth = AuthInfo::new(
|
||
"sk-ant-bad\r\nX-Inject: 1".to_string(),
|
||
AuthStrategy::Anthropic,
|
||
);
|
||
|
||
let result = adapter.get_auth_headers(&auth);
|
||
assert!(result.is_err(), "expected AuthError, got Ok");
|
||
assert!(matches!(result, Err(ProxyError::AuthError(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_auth_openrouter() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
|
||
"OPENROUTER_API_KEY": "sk-or-test-key"
|
||
}
|
||
}));
|
||
|
||
let auth = adapter.extract_auth(&provider).unwrap();
|
||
assert_eq!(auth.api_key, "sk-or-test-key");
|
||
assert_eq!(auth.strategy, AuthStrategy::Bearer);
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_auth_gemini_api_key() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let provider = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com/v1beta",
|
||
"GEMINI_API_KEY": "gemini-test-key"
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("gemini_native".to_string()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
|
||
let auth = adapter.extract_auth(&provider).unwrap();
|
||
assert_eq!(auth.api_key, "gemini-test-key");
|
||
assert_eq!(auth.strategy, AuthStrategy::Google);
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_auth_claude_auth_mode() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
|
||
"ANTHROPIC_AUTH_TOKEN": "sk-proxy-key"
|
||
},
|
||
"auth_mode": "bearer_only"
|
||
}));
|
||
|
||
let auth = adapter.extract_auth(&provider).unwrap();
|
||
assert_eq!(auth.api_key, "sk-proxy-key");
|
||
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_auth_claude_auth_env_mode() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
|
||
"ANTHROPIC_AUTH_TOKEN": "sk-proxy-key",
|
||
"AUTH_MODE": "bearer_only"
|
||
}
|
||
}));
|
||
|
||
let auth = adapter.extract_auth(&provider).unwrap();
|
||
assert_eq!(auth.api_key, "sk-proxy-key");
|
||
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
|
||
}
|
||
|
||
/// Regression: a Gemini OAuth credential JSON that carries only a
|
||
/// refresh_token (no active access_token) must not be surfaced as an
|
||
/// `AuthInfo` whose bearer would be empty. Without the guard, downstream
|
||
/// header injection produces `Authorization: Bearer ` and a deterministic
|
||
/// 401 from upstream.
|
||
#[test]
|
||
fn test_extract_auth_gemini_cli_refresh_only_json_does_not_expose_empty_bearer() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let refresh_only_json =
|
||
r#"{"refresh_token":"rt-abc","client_id":"cid","client_secret":"cs"}"#;
|
||
let provider = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
|
||
"ANTHROPIC_API_KEY": refresh_only_json
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("gemini_native".to_string()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
|
||
let auth = adapter.extract_auth(&provider).unwrap();
|
||
// access_token must not be surfaced as `Some("")` — the OAuth header
|
||
// builder uses `access_token.as_ref().unwrap_or(&api_key)`, so a
|
||
// `Some("")` would win over the raw key and emit `Bearer `.
|
||
assert!(
|
||
auth.access_token.as_deref().is_none_or(|t| !t.is_empty()),
|
||
"empty access_token leaked into AuthInfo"
|
||
);
|
||
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||
}
|
||
|
||
/// Companion case: a JSON credential with an empty-string `access_token`
|
||
/// field (the shape an expired credential can take after partial writes)
|
||
/// must degrade the same way.
|
||
#[test]
|
||
fn test_extract_auth_gemini_cli_empty_access_token_degrades_to_raw_key() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let expired_json = r#"{"access_token":"","refresh_token":"rt-abc","client_id":"cid","client_secret":"cs"}"#;
|
||
let provider = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
|
||
"ANTHROPIC_API_KEY": expired_json
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("gemini_native".to_string()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
|
||
let auth = adapter.extract_auth(&provider).unwrap();
|
||
assert!(
|
||
auth.access_token.as_deref().is_none_or(|t| !t.is_empty()),
|
||
"empty access_token leaked into AuthInfo"
|
||
);
|
||
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||
}
|
||
|
||
/// Counter-case: a well-formed JSON credential with a non-empty
|
||
/// access_token must still flow through the OAuth path unchanged.
|
||
#[test]
|
||
fn test_extract_auth_gemini_cli_valid_json_keeps_access_token() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let valid_json = r#"{"access_token":"ya29.valid","refresh_token":"rt"}"#;
|
||
let provider = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
|
||
"ANTHROPIC_API_KEY": valid_json
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("gemini_native".to_string()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
|
||
let auth = adapter.extract_auth(&provider).unwrap();
|
||
assert_eq!(auth.access_token.as_deref(), Some("ya29.valid"));
|
||
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||
}
|
||
|
||
/// 回归:从 oauth_creds.json 复制时常带前导换行/空格。未 trim 时
|
||
/// `starts_with('{')` 会落空,导致误分类为 `ProviderType::Gemini`,再
|
||
/// 以 raw JSON 当 `x-goog-api-key` 发出去触发 401。trim 应在 provider
|
||
/// 类型判定和 OAuth 解析前统一生效。
|
||
#[test]
|
||
fn test_extract_auth_gemini_cli_json_with_leading_whitespace_classifies_correctly() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let valid_json = r#"{"access_token":"ya29.valid","refresh_token":"rt"}"#;
|
||
let key_with_whitespace = format!("\n {valid_json}\n");
|
||
let provider = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
|
||
"ANTHROPIC_API_KEY": key_with_whitespace
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("gemini_native".to_string()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
|
||
assert_eq!(adapter.provider_type(&provider), ProviderType::GeminiCli);
|
||
|
||
let auth = adapter.extract_auth(&provider).unwrap();
|
||
assert_eq!(auth.access_token.as_deref(), Some("ya29.valid"));
|
||
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||
}
|
||
|
||
/// 回归:裸 `ya29.` access_token 若带前导换行,也应被 trim 后识别为
|
||
/// Gemini CLI OAuth,避免前导空白把 `starts_with("ya29.")` 检查顶穿。
|
||
#[test]
|
||
fn test_extract_auth_gemini_cli_access_token_with_leading_newline_classifies_correctly() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let provider = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
|
||
"ANTHROPIC_API_KEY": "\nya29.raw-token-value\n"
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("gemini_native".to_string()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
|
||
assert_eq!(adapter.provider_type(&provider), ProviderType::GeminiCli);
|
||
|
||
let auth = adapter.extract_auth(&provider).unwrap();
|
||
assert_eq!(auth.access_token.as_deref(), Some("ya29.raw-token-value"));
|
||
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||
}
|
||
|
||
#[test]
|
||
fn test_provider_type_detection() {
|
||
let adapter = ClaudeAdapter::new();
|
||
|
||
// Anthropic 官方
|
||
let anthropic = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||
"ANTHROPIC_AUTH_TOKEN": "sk-ant-test"
|
||
}
|
||
}));
|
||
assert_eq!(adapter.provider_type(&anthropic), ProviderType::Claude);
|
||
|
||
// OpenRouter
|
||
let openrouter = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
|
||
"OPENROUTER_API_KEY": "sk-or-test"
|
||
}
|
||
}));
|
||
assert_eq!(adapter.provider_type(&openrouter), ProviderType::OpenRouter);
|
||
|
||
// ClaudeAuth
|
||
let claude_auth = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
|
||
"ANTHROPIC_AUTH_TOKEN": "sk-test"
|
||
},
|
||
"auth_mode": "bearer_only"
|
||
}));
|
||
assert_eq!(
|
||
adapter.provider_type(&claude_auth),
|
||
ProviderType::ClaudeAuth
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_url_anthropic() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages");
|
||
assert_eq!(url, "https://api.anthropic.com/v1/messages");
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_url_openrouter() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
|
||
assert_eq!(url, "https://openrouter.ai/api/v1/messages");
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_url_no_beta_for_other_endpoints() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let url = adapter.build_url("https://api.anthropic.com", "/v1/complete");
|
||
assert_eq!(url, "https://api.anthropic.com/v1/complete");
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_url_preserve_existing_query() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?foo=bar");
|
||
assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar");
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_url_no_beta_for_github_copilot() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let url = adapter.build_url("https://api.githubcopilot.com", "/v1/messages");
|
||
assert_eq!(url, "https://api.githubcopilot.com/v1/messages");
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_url_no_beta_for_openai_chat_completions() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let url = adapter.build_url("https://integrate.api.nvidia.com", "/v1/chat/completions");
|
||
assert_eq!(url, "https://integrate.api.nvidia.com/v1/chat/completions");
|
||
}
|
||
|
||
#[test]
|
||
fn test_needs_transform() {
|
||
let adapter = ClaudeAdapter::new();
|
||
|
||
// Default: no transform (anthropic format) - no meta
|
||
let anthropic_provider = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||
}
|
||
}));
|
||
assert!(!adapter.needs_transform(&anthropic_provider));
|
||
|
||
// Explicit anthropic format in meta: no transform
|
||
let explicit_anthropic = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("anthropic".to_string()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
assert!(!adapter.needs_transform(&explicit_anthropic));
|
||
|
||
// Legacy settings_config.api_format: openai_chat should enable transform
|
||
let legacy_settings_api_format = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||
},
|
||
"api_format": "openai_chat"
|
||
}));
|
||
assert!(adapter.needs_transform(&legacy_settings_api_format));
|
||
|
||
// Legacy openrouter_compat_mode: bool/number/string should enable transform
|
||
let legacy_openrouter_bool = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||
},
|
||
"openrouter_compat_mode": true
|
||
}));
|
||
assert!(adapter.needs_transform(&legacy_openrouter_bool));
|
||
|
||
let legacy_openrouter_num = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||
},
|
||
"openrouter_compat_mode": 1
|
||
}));
|
||
assert!(adapter.needs_transform(&legacy_openrouter_num));
|
||
|
||
let legacy_openrouter_str = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||
},
|
||
"openrouter_compat_mode": "true"
|
||
}));
|
||
assert!(adapter.needs_transform(&legacy_openrouter_str));
|
||
|
||
// OpenAI Chat format in meta: needs transform
|
||
let openai_chat_provider = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("openai_chat".to_string()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
assert!(adapter.needs_transform(&openai_chat_provider));
|
||
|
||
// OpenAI Responses format in meta: needs transform
|
||
let openai_responses_provider = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("openai_responses".to_string()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
assert!(adapter.needs_transform(&openai_responses_provider));
|
||
|
||
let gemini_native_provider = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
|
||
"ANTHROPIC_API_KEY": "test-key"
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("gemini_native".to_string()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
assert!(adapter.needs_transform(&gemini_native_provider));
|
||
assert_eq!(
|
||
adapter.provider_type(&gemini_native_provider),
|
||
ProviderType::Gemini
|
||
);
|
||
|
||
// meta takes precedence over legacy settings_config fields
|
||
let meta_precedence_over_settings = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||
},
|
||
"api_format": "openai_chat",
|
||
"openrouter_compat_mode": true
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("anthropic".to_string()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
assert!(!adapter.needs_transform(&meta_precedence_over_settings));
|
||
|
||
// Unknown format in meta: default to anthropic (no transform)
|
||
let unknown_format = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("unknown".to_string()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
assert!(!adapter.needs_transform(&unknown_format));
|
||
}
|
||
|
||
#[test]
|
||
fn test_github_copilot_detection_by_url() {
|
||
let adapter = ClaudeAdapter::new();
|
||
|
||
// GitHub Copilot by base_url
|
||
let copilot = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
|
||
}
|
||
}));
|
||
assert_eq!(adapter.provider_type(&copilot), ProviderType::GitHubCopilot);
|
||
}
|
||
|
||
#[test]
|
||
fn test_github_copilot_detection_by_meta() {
|
||
let adapter = ClaudeAdapter::new();
|
||
|
||
// GitHub Copilot by meta.provider_type
|
||
let copilot_meta = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
provider_type: Some("github_copilot".to_string()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
assert_eq!(
|
||
adapter.provider_type(&copilot_meta),
|
||
ProviderType::GitHubCopilot
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_github_copilot_auth() {
|
||
let adapter = ClaudeAdapter::new();
|
||
|
||
let copilot = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
|
||
}
|
||
}));
|
||
|
||
let auth = adapter.extract_auth(&copilot).unwrap();
|
||
assert_eq!(auth.strategy, AuthStrategy::GitHubCopilot);
|
||
}
|
||
|
||
#[test]
|
||
fn test_github_copilot_needs_transform() {
|
||
let adapter = ClaudeAdapter::new();
|
||
|
||
let copilot = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
|
||
}
|
||
}));
|
||
|
||
// GitHub Copilot always needs transform
|
||
assert!(adapter.needs_transform(&copilot));
|
||
}
|
||
|
||
#[test]
|
||
fn test_transform_claude_request_for_api_format_responses() {
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
|
||
}
|
||
}));
|
||
let body = json!({
|
||
"model": "gpt-5.4",
|
||
"messages": [{ "role": "user", "content": "hello" }],
|
||
"max_tokens": 128
|
||
});
|
||
|
||
let transformed = transform_claude_request_for_api_format(
|
||
body,
|
||
&provider,
|
||
"openai_responses",
|
||
None,
|
||
None,
|
||
)
|
||
.unwrap();
|
||
|
||
assert_eq!(transformed["model"], "gpt-5.4");
|
||
assert!(transformed.get("input").is_some());
|
||
assert!(transformed.get("max_output_tokens").is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn test_transform_claude_request_openai_chat_streaming_injects_include_usage() {
|
||
let provider = create_provider(json!({
|
||
"env": { "ANTHROPIC_BASE_URL": "https://openrouter.ai/api/v1" }
|
||
}));
|
||
// 流式请求必须注入 stream_options.include_usage,否则 OpenAI 兼容上游不在
|
||
// SSE 末尾吐 usage → 转换出的 Anthropic message_delta 全 0 → 整笔 usage 漏记。
|
||
let body = json!({
|
||
"model": "moonshotai/kimi-k2",
|
||
"messages": [{ "role": "user", "content": "hello" }],
|
||
"max_tokens": 128,
|
||
"stream": true
|
||
});
|
||
let transformed =
|
||
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
|
||
.unwrap();
|
||
assert_eq!(transformed["stream"], true);
|
||
assert_eq!(transformed["stream_options"]["include_usage"], true);
|
||
}
|
||
|
||
#[test]
|
||
fn test_transform_claude_request_openai_chat_non_streaming_omits_stream_options() {
|
||
let provider = create_provider(json!({
|
||
"env": { "ANTHROPIC_BASE_URL": "https://openrouter.ai/api/v1" }
|
||
}));
|
||
// 非流式请求不应注入 stream_options(usage 在非流式响应体里恒有)。
|
||
let body = json!({
|
||
"model": "moonshotai/kimi-k2",
|
||
"messages": [{ "role": "user", "content": "hello" }],
|
||
"max_tokens": 128
|
||
});
|
||
let transformed =
|
||
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
|
||
.unwrap();
|
||
assert!(transformed.get("stream_options").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_transform_claude_request_for_codex_oauth_uses_session_cache_key() {
|
||
let provider = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex"
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("openai_responses".to_string()),
|
||
provider_type: Some("codex_oauth".to_string()),
|
||
..ProviderMeta::default()
|
||
},
|
||
);
|
||
let body = json!({
|
||
"model": "gpt-5.4",
|
||
"messages": [{ "role": "user", "content": "hello" }],
|
||
"max_tokens": 128
|
||
});
|
||
|
||
let transformed = transform_claude_request_for_api_format(
|
||
body,
|
||
&provider,
|
||
"openai_responses",
|
||
Some("session-123"),
|
||
None,
|
||
)
|
||
.unwrap();
|
||
|
||
assert_eq!(transformed["prompt_cache_key"], "session-123");
|
||
}
|
||
|
||
#[test]
|
||
fn test_transform_claude_request_for_codex_oauth_without_session_omits_cache_key() {
|
||
let provider = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex"
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("openai_responses".to_string()),
|
||
provider_type: Some("codex_oauth".to_string()),
|
||
..ProviderMeta::default()
|
||
},
|
||
);
|
||
let body = json!({
|
||
"model": "gpt-5.4",
|
||
"messages": [{ "role": "user", "content": "hello" }],
|
||
"max_tokens": 128
|
||
});
|
||
|
||
let transformed = transform_claude_request_for_api_format(
|
||
body,
|
||
&provider,
|
||
"openai_responses",
|
||
None,
|
||
None,
|
||
)
|
||
.unwrap();
|
||
|
||
assert!(transformed.get("prompt_cache_key").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_transform_claude_request_for_responses_uses_session_cache_key() {
|
||
let provider = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.openai.example.com"
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("openai_responses".to_string()),
|
||
..ProviderMeta::default()
|
||
},
|
||
);
|
||
let body = json!({
|
||
"model": "gpt-5.4",
|
||
"messages": [{ "role": "user", "content": "hello" }],
|
||
"max_tokens": 128
|
||
});
|
||
|
||
let transformed = transform_claude_request_for_api_format(
|
||
body,
|
||
&provider,
|
||
"openai_responses",
|
||
Some("claude-session-123"),
|
||
None,
|
||
)
|
||
.unwrap();
|
||
|
||
assert_eq!(transformed["prompt_cache_key"], "claude-session-123");
|
||
}
|
||
|
||
#[test]
|
||
fn test_transform_claude_request_for_responses_without_session_omits_cache_key() {
|
||
let provider = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.openai.example.com"
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("openai_responses".to_string()),
|
||
..ProviderMeta::default()
|
||
},
|
||
);
|
||
let body = json!({
|
||
"model": "gpt-5.4",
|
||
"messages": [{ "role": "user", "content": "hello" }],
|
||
"max_tokens": 128
|
||
});
|
||
|
||
let transformed = transform_claude_request_for_api_format(
|
||
body,
|
||
&provider,
|
||
"openai_responses",
|
||
None,
|
||
None,
|
||
)
|
||
.unwrap();
|
||
|
||
assert!(transformed.get("prompt_cache_key").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_transform_claude_request_for_codex_oauth_keeps_explicit_cache_key() {
|
||
let provider = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex"
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("openai_responses".to_string()),
|
||
provider_type: Some("codex_oauth".to_string()),
|
||
prompt_cache_key: Some("explicit-cache-key".to_string()),
|
||
..ProviderMeta::default()
|
||
},
|
||
);
|
||
let body = json!({
|
||
"model": "gpt-5.4",
|
||
"messages": [{ "role": "user", "content": "hello" }],
|
||
"max_tokens": 128
|
||
});
|
||
|
||
let transformed = transform_claude_request_for_api_format(
|
||
body,
|
||
&provider,
|
||
"openai_responses",
|
||
Some("session-123"),
|
||
None,
|
||
)
|
||
.unwrap();
|
||
|
||
assert_eq!(transformed["prompt_cache_key"], "explicit-cache-key");
|
||
}
|
||
|
||
#[test]
|
||
fn test_transform_claude_request_for_api_format_codex_oauth_fast_mode_off() {
|
||
let provider = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex"
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
provider_type: Some("codex_oauth".to_string()),
|
||
codex_fast_mode: Some(false),
|
||
..ProviderMeta::default()
|
||
},
|
||
);
|
||
let body = json!({
|
||
"model": "gpt-5.4",
|
||
"messages": [{ "role": "user", "content": "hello" }],
|
||
"max_tokens": 128
|
||
});
|
||
|
||
let transformed = transform_claude_request_for_api_format(
|
||
body,
|
||
&provider,
|
||
"openai_responses",
|
||
None,
|
||
None,
|
||
)
|
||
.unwrap();
|
||
|
||
assert_eq!(transformed["store"], json!(false));
|
||
assert!(transformed.get("service_tier").is_none());
|
||
assert_eq!(
|
||
transformed["include"],
|
||
json!(["reasoning.encrypted_content"])
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_transform_claude_request_for_api_format_gemini_native() {
|
||
let provider = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
|
||
"ANTHROPIC_API_KEY": "test-key"
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("gemini_native".to_string()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
let body = json!({
|
||
"model": "gemini-2.5-pro",
|
||
"system": "You are helpful.",
|
||
"messages": [{ "role": "user", "content": "hello" }],
|
||
"max_tokens": 64
|
||
});
|
||
|
||
let transformed =
|
||
transform_claude_request_for_api_format(body, &provider, "gemini_native", None, None)
|
||
.unwrap();
|
||
|
||
assert!(transformed.get("contents").is_some());
|
||
assert_eq!(
|
||
transformed["systemInstruction"]["parts"][0]["text"],
|
||
"You are helpful."
|
||
);
|
||
assert_eq!(transformed["generationConfig"]["maxOutputTokens"], 64);
|
||
}
|
||
|
||
#[test]
|
||
fn test_transform_claude_request_for_api_format_openai_chat_skips_prompt_cache_key_by_default()
|
||
{
|
||
let provider = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.example.com",
|
||
"ANTHROPIC_API_KEY": "test-key"
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("openai_chat".to_string()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
let body = json!({
|
||
"model": "gpt-5.4",
|
||
"messages": [{ "role": "user", "content": "hello" }],
|
||
"max_tokens": 64
|
||
});
|
||
|
||
let transformed =
|
||
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
|
||
.unwrap();
|
||
|
||
assert!(transformed.get("prompt_cache_key").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_transform_claude_request_for_api_format_openai_chat_keeps_explicit_prompt_cache_key() {
|
||
let provider = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.example.com",
|
||
"ANTHROPIC_API_KEY": "test-key"
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("openai_chat".to_string()),
|
||
prompt_cache_key: Some("claude-cache-route".to_string()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
let body = json!({
|
||
"model": "gpt-5.4",
|
||
"messages": [{ "role": "user", "content": "hello" }],
|
||
"max_tokens": 64
|
||
});
|
||
|
||
let transformed =
|
||
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
|
||
.unwrap();
|
||
|
||
assert_eq!(transformed["prompt_cache_key"], "claude-cache-route");
|
||
}
|
||
|
||
#[test]
|
||
fn test_transform_openai_chat_skips_reasoning_content_for_generic_provider() {
|
||
let provider = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.example.com",
|
||
"ANTHROPIC_API_KEY": "test-key"
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("openai_chat".to_string()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
let body = json!({
|
||
"model": "gpt-5.4",
|
||
"max_tokens": 64,
|
||
"messages": [{
|
||
"role": "assistant",
|
||
"content": [
|
||
{"type": "thinking", "thinking": "I should call the tool."},
|
||
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
|
||
]
|
||
}]
|
||
});
|
||
|
||
let transformed =
|
||
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
|
||
.unwrap();
|
||
|
||
let msg = &transformed["messages"][0];
|
||
assert!(msg.get("tool_calls").is_some());
|
||
assert!(msg.get("reasoning_content").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_transform_openai_chat_preserves_reasoning_content_for_kimi_provider() {
|
||
let provider = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.moonshot.cn/v1",
|
||
"ANTHROPIC_API_KEY": "test-key"
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("openai_chat".to_string()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
let body = json!({
|
||
"model": "kimi-k2.6",
|
||
"max_tokens": 64,
|
||
"messages": [{
|
||
"role": "assistant",
|
||
"content": [
|
||
{"type": "thinking", "thinking": "I should call the tool."},
|
||
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
|
||
]
|
||
}]
|
||
});
|
||
|
||
let transformed =
|
||
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
|
||
.unwrap();
|
||
|
||
let msg = &transformed["messages"][0];
|
||
assert_eq!(msg["reasoning_content"], "I should call the tool.");
|
||
assert!(msg.get("tool_calls").is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn test_transform_openai_chat_preserves_reasoning_content_for_deepseek_provider() {
|
||
let provider = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/v1",
|
||
"ANTHROPIC_API_KEY": "test-key"
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("openai_chat".to_string()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
let body = json!({
|
||
"model": "deepseek-v4-flash",
|
||
"max_tokens": 64,
|
||
"messages": [{
|
||
"role": "assistant",
|
||
"content": [
|
||
{"type": "thinking", "thinking": "I should call the tool."},
|
||
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
|
||
]
|
||
}]
|
||
});
|
||
|
||
let transformed =
|
||
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
|
||
.unwrap();
|
||
|
||
let msg = &transformed["messages"][0];
|
||
assert_eq!(msg["reasoning_content"], "I should call the tool.");
|
||
assert!(msg.get("tool_calls").is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn test_transform_openai_chat_preserves_reasoning_content_for_mimo_provider() {
|
||
let provider = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.xiaomimimo.com/v1",
|
||
"ANTHROPIC_API_KEY": "test-key"
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("openai_chat".to_string()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
let body = json!({
|
||
"model": "mimo-v2.5-pro",
|
||
"max_tokens": 64,
|
||
"messages": [{
|
||
"role": "assistant",
|
||
"content": [
|
||
{"type": "thinking", "thinking": "I should call the tool."},
|
||
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
|
||
]
|
||
}]
|
||
});
|
||
|
||
let transformed =
|
||
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
|
||
.unwrap();
|
||
|
||
let msg = &transformed["messages"][0];
|
||
assert_eq!(msg["reasoning_content"], "I should call the tool.");
|
||
assert!(msg.get("tool_calls").is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn test_deepseek_anthropic_tool_history_injects_missing_thinking() {
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
|
||
"ANTHROPIC_API_KEY": "test-key"
|
||
}
|
||
}));
|
||
let mut body = json!({
|
||
"model": "deepseek-v4-pro",
|
||
"messages": [{
|
||
"role": "assistant",
|
||
"content": [
|
||
{"type": "text", "text": "I will inspect the repo."},
|
||
{"type": "tool_use", "id": "call_123", "name": "read_file", "input": {"path": "README.md"}}
|
||
]
|
||
}]
|
||
});
|
||
|
||
let changed = normalize_anthropic_tool_thinking_history_for_provider(
|
||
&mut body,
|
||
&provider,
|
||
"anthropic",
|
||
);
|
||
|
||
assert!(changed);
|
||
let content = body["messages"][0]["content"].as_array().unwrap();
|
||
assert_eq!(content[0]["type"], "thinking");
|
||
assert_eq!(content[0]["thinking"], ANTHROPIC_THINKING_PLACEHOLDER);
|
||
assert_eq!(content[1]["type"], "text");
|
||
assert_eq!(content[2]["type"], "tool_use");
|
||
}
|
||
|
||
#[test]
|
||
fn test_anthropic_messages_no_longer_hoists_system_role_messages() {
|
||
// After reverting #3775, role=system messages are left in `messages[]`
|
||
// (DeepSeek's endpoint accepts them natively) and the top-level `system`
|
||
// field is untouched, preserving the request prefix.
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
|
||
"ANTHROPIC_API_KEY": "test-key"
|
||
}
|
||
}));
|
||
let mut body = json!({
|
||
"system": "Existing top-level system.",
|
||
"model": "deepseek-v4-pro",
|
||
"messages": [
|
||
{ "role": "system", "content": "Message system one." },
|
||
{ "role": "user", "content": "hello" },
|
||
{
|
||
"role": "system",
|
||
"content": [{ "type": "text", "text": "Message system two." }]
|
||
}
|
||
]
|
||
});
|
||
|
||
let changed = normalize_anthropic_messages_for_provider(&mut body, &provider, "anthropic");
|
||
|
||
assert!(!changed);
|
||
let messages = body["messages"].as_array().unwrap();
|
||
assert_eq!(messages.len(), 3);
|
||
assert_eq!(messages[0]["role"], "system");
|
||
assert_eq!(messages[1]["role"], "user");
|
||
assert_eq!(messages[2]["role"], "system");
|
||
assert_eq!(body["system"], "Existing top-level system.");
|
||
}
|
||
|
||
#[test]
|
||
fn test_anthropic_system_role_messages_skip_non_anthropic_format() {
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/v1",
|
||
"ANTHROPIC_API_KEY": "test-key"
|
||
}
|
||
}));
|
||
let mut body = json!({
|
||
"model": "deepseek-v4-pro",
|
||
"messages": [
|
||
{ "role": "system", "content": "Keep in messages." },
|
||
{ "role": "user", "content": "hello" }
|
||
]
|
||
});
|
||
|
||
let changed =
|
||
normalize_anthropic_messages_for_provider(&mut body, &provider, "openai_chat");
|
||
|
||
assert!(!changed);
|
||
assert!(body.get("system").is_none());
|
||
assert_eq!(body["messages"][0]["role"], "system");
|
||
}
|
||
|
||
#[test]
|
||
fn test_kimi_anthropic_tool_history_injects_missing_thinking() {
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.kimi.com/coding",
|
||
"ANTHROPIC_API_KEY": "test-key"
|
||
}
|
||
}));
|
||
let mut body = json!({
|
||
"model": "kimi-for-coding",
|
||
"messages": [{
|
||
"role": "assistant",
|
||
"content": [
|
||
{"type": "tool_use", "id": "call_123", "name": "read_file", "input": {"path": "README.md"}}
|
||
]
|
||
}]
|
||
});
|
||
|
||
let changed = normalize_anthropic_tool_thinking_history_for_provider(
|
||
&mut body,
|
||
&provider,
|
||
"anthropic",
|
||
);
|
||
|
||
assert!(changed);
|
||
let content = body["messages"][0]["content"].as_array().unwrap();
|
||
assert_eq!(content[0]["type"], "thinking");
|
||
assert_eq!(content[0]["thinking"], ANTHROPIC_THINKING_PLACEHOLDER);
|
||
assert_eq!(content[1]["type"], "tool_use");
|
||
}
|
||
|
||
#[test]
|
||
fn test_deepseek_anthropic_tool_history_rewrites_redacted_thinking() {
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
|
||
"ANTHROPIC_API_KEY": "test-key"
|
||
}
|
||
}));
|
||
let mut body = json!({
|
||
"model": "deepseek-v4-pro",
|
||
"messages": [{
|
||
"role": "assistant",
|
||
"content": [
|
||
{"type": "redacted_thinking", "data": "opaque"},
|
||
{"type": "tool_use", "id": "call_123", "name": "read_file", "input": {"path": "README.md"}}
|
||
]
|
||
}]
|
||
});
|
||
|
||
let changed = normalize_anthropic_tool_thinking_history_for_provider(
|
||
&mut body,
|
||
&provider,
|
||
"anthropic",
|
||
);
|
||
|
||
assert!(changed);
|
||
let content = body["messages"][0]["content"].as_array().unwrap();
|
||
assert_eq!(content[0]["type"], "thinking");
|
||
assert_eq!(
|
||
content[0]["thinking"],
|
||
ANTHROPIC_REDACTED_THINKING_PLACEHOLDER
|
||
);
|
||
assert!(content[0].get("data").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_deepseek_anthropic_tool_history_keeps_thinking_text_but_drops_signature() {
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
|
||
"ANTHROPIC_API_KEY": "test-key"
|
||
}
|
||
}));
|
||
let mut body = json!({
|
||
"model": "deepseek-v4-pro",
|
||
"messages": [{
|
||
"role": "assistant",
|
||
"content": [
|
||
{"type": "thinking", "thinking": "Need to inspect the file.", "signature": "anthropic-signature"},
|
||
{"type": "tool_use", "id": "call_123", "name": "read_file", "input": {"path": "README.md"}}
|
||
]
|
||
}]
|
||
});
|
||
|
||
let changed = normalize_anthropic_tool_thinking_history_for_provider(
|
||
&mut body,
|
||
&provider,
|
||
"anthropic",
|
||
);
|
||
|
||
assert!(changed);
|
||
let content = body["messages"][0]["content"].as_array().unwrap();
|
||
assert_eq!(content[0]["type"], "thinking");
|
||
assert_eq!(content[0]["thinking"], "Need to inspect the file.");
|
||
assert!(content[0].get("signature").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_generic_anthropic_tool_history_is_not_modified() {
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.example.com/anthropic",
|
||
"ANTHROPIC_API_KEY": "test-key"
|
||
}
|
||
}));
|
||
let mut body = json!({
|
||
"model": "claude-sonnet-4.6",
|
||
"messages": [{
|
||
"role": "assistant",
|
||
"content": [
|
||
{"type": "tool_use", "id": "call_123", "name": "read_file", "input": {"path": "README.md"}}
|
||
]
|
||
}]
|
||
});
|
||
let original = body.clone();
|
||
|
||
let changed = normalize_anthropic_tool_thinking_history_for_provider(
|
||
&mut body,
|
||
&provider,
|
||
"anthropic",
|
||
);
|
||
|
||
assert!(!changed);
|
||
assert_eq!(body, original);
|
||
}
|
||
|
||
// ==================== normalize_deepseek_thinking_disabled_strip_effort 测试 ====================
|
||
|
||
fn deepseek_official_provider() -> Provider {
|
||
create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
|
||
"ANTHROPIC_API_KEY": "test-key"
|
||
}
|
||
}))
|
||
}
|
||
|
||
#[test]
|
||
fn test_deepseek_official_strips_output_config_effort() {
|
||
let mut body = json!({
|
||
"model": "deepseek-v4-pro",
|
||
"thinking": { "type": "disabled" },
|
||
"output_config": { "effort": "max" },
|
||
"max_tokens": 100000
|
||
});
|
||
|
||
let changed = normalize_deepseek_thinking_disabled_strip_effort(
|
||
&mut body,
|
||
&deepseek_official_provider(),
|
||
);
|
||
|
||
assert!(changed);
|
||
assert_eq!(body["thinking"]["type"], "disabled");
|
||
assert!(body.get("output_config").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_deepseek_official_strips_reasoning_effort() {
|
||
let mut body = json!({
|
||
"model": "deepseek-v4-pro",
|
||
"thinking": { "type": "disabled" },
|
||
"reasoning_effort": "high",
|
||
"max_tokens": 100000
|
||
});
|
||
|
||
let changed = normalize_deepseek_thinking_disabled_strip_effort(
|
||
&mut body,
|
||
&deepseek_official_provider(),
|
||
);
|
||
|
||
assert!(changed);
|
||
assert_eq!(body["thinking"]["type"], "disabled");
|
||
assert!(body.get("reasoning_effort").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_deepseek_official_strips_both_effort_fields() {
|
||
let mut body = json!({
|
||
"model": "deepseek-v4-pro",
|
||
"thinking": { "type": "disabled" },
|
||
"output_config": { "effort": "max" },
|
||
"reasoning_effort": "high",
|
||
"max_tokens": 100000
|
||
});
|
||
|
||
let changed = normalize_deepseek_thinking_disabled_strip_effort(
|
||
&mut body,
|
||
&deepseek_official_provider(),
|
||
);
|
||
|
||
assert!(changed);
|
||
assert_eq!(body["thinking"]["type"], "disabled");
|
||
assert!(body.get("output_config").is_none());
|
||
assert!(body.get("reasoning_effort").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_deepseek_official_no_effort_no_change() {
|
||
let mut body = json!({
|
||
"model": "deepseek-v4-pro",
|
||
"thinking": { "type": "disabled" },
|
||
"max_tokens": 100000
|
||
});
|
||
let original = body.clone();
|
||
|
||
let changed = normalize_deepseek_thinking_disabled_strip_effort(
|
||
&mut body,
|
||
&deepseek_official_provider(),
|
||
);
|
||
|
||
assert!(!changed);
|
||
assert_eq!(body, original);
|
||
}
|
||
|
||
#[test]
|
||
fn test_deepseek_official_preserves_output_config_other_fields() {
|
||
let mut body = json!({
|
||
"model": "deepseek-v4-pro",
|
||
"thinking": { "type": "disabled" },
|
||
"output_config": { "effort": "max", "temperature": 0.5 },
|
||
"max_tokens": 100000
|
||
});
|
||
|
||
let changed = normalize_deepseek_thinking_disabled_strip_effort(
|
||
&mut body,
|
||
&deepseek_official_provider(),
|
||
);
|
||
|
||
assert!(changed);
|
||
assert_eq!(body["output_config"]["temperature"], 0.5);
|
||
assert!(body["output_config"].get("effort").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_deepseek_official_non_disabled_not_modified() {
|
||
let cases = vec![
|
||
(
|
||
"enabled",
|
||
json!({ "type": "enabled", "budget_tokens": 16000 }),
|
||
),
|
||
("adaptive", json!({ "type": "adaptive" })),
|
||
];
|
||
|
||
for (label, thinking_value) in cases {
|
||
let mut body = json!({
|
||
"model": "deepseek-v4-pro",
|
||
"thinking": thinking_value,
|
||
"output_config": { "effort": "max" },
|
||
"max_tokens": 100000
|
||
});
|
||
let original = body.clone();
|
||
|
||
let changed = normalize_deepseek_thinking_disabled_strip_effort(
|
||
&mut body,
|
||
&deepseek_official_provider(),
|
||
);
|
||
|
||
assert!(!changed, "should not modify thinking.type={label}");
|
||
assert_eq!(body, original);
|
||
}
|
||
|
||
// missing thinking field entirely
|
||
let mut body = json!({
|
||
"model": "deepseek-v4-pro",
|
||
"output_config": { "effort": "max" },
|
||
"max_tokens": 100000
|
||
});
|
||
let original = body.clone();
|
||
assert!(!normalize_deepseek_thinking_disabled_strip_effort(
|
||
&mut body,
|
||
&deepseek_official_provider()
|
||
));
|
||
assert_eq!(body, original);
|
||
}
|
||
|
||
#[test]
|
||
fn test_deepseek_official_url_with_trailing_slash() {
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic/",
|
||
"ANTHROPIC_API_KEY": "test-key"
|
||
}
|
||
}));
|
||
let mut body = json!({
|
||
"model": "deepseek-v4-pro",
|
||
"thinking": { "type": "disabled" },
|
||
"output_config": { "effort": "max" },
|
||
"max_tokens": 100000
|
||
});
|
||
|
||
let changed = normalize_deepseek_thinking_disabled_strip_effort(&mut body, &provider);
|
||
|
||
assert!(changed);
|
||
assert!(body.get("output_config").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_deepseek_official_detected_via_base_url_fallback() {
|
||
let provider = create_provider(json!({
|
||
"base_url": "https://api.deepseek.com/anthropic",
|
||
"ANTHROPIC_API_KEY": "test-key"
|
||
}));
|
||
let mut body = json!({
|
||
"model": "deepseek-v4-pro",
|
||
"thinking": { "type": "disabled" },
|
||
"reasoning_effort": "high",
|
||
"max_tokens": 100000
|
||
});
|
||
|
||
let changed = normalize_deepseek_thinking_disabled_strip_effort(&mut body, &provider);
|
||
|
||
assert!(changed);
|
||
assert!(body.get("reasoning_effort").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_non_deepseek_endpoint_not_modified() {
|
||
let providers = vec![
|
||
create_provider(json!({
|
||
"env": { "ANTHROPIC_BASE_URL": "https://other-api.com/anthropic", "ANTHROPIC_API_KEY": "test-key" }
|
||
})),
|
||
create_provider(json!({
|
||
"env": { "ANTHROPIC_BASE_URL": "https://api.anthropic.com", "ANTHROPIC_API_KEY": "test-key" }
|
||
})),
|
||
];
|
||
|
||
for provider in providers {
|
||
let mut body = json!({
|
||
"model": "deepseek-v4-pro",
|
||
"thinking": { "type": "disabled" },
|
||
"output_config": { "effort": "max" },
|
||
"max_tokens": 100000
|
||
});
|
||
let original = body.clone();
|
||
|
||
let changed = normalize_deepseek_thinking_disabled_strip_effort(&mut body, &provider);
|
||
|
||
assert!(
|
||
!changed,
|
||
"should not modify for {}",
|
||
provider.settings_config["env"]["ANTHROPIC_BASE_URL"]
|
||
);
|
||
assert_eq!(body, original);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_normalize_messages_pipeline_strips_effort_for_deepseek() {
|
||
let mut body = json!({
|
||
"model": "deepseek-v4-pro",
|
||
"thinking": { "type": "disabled" },
|
||
"output_config": { "effort": "max" },
|
||
"max_tokens": 100000,
|
||
"messages": [{ "role": "user", "content": "hello" }]
|
||
});
|
||
|
||
let changed = normalize_anthropic_messages_for_provider(
|
||
&mut body,
|
||
&deepseek_official_provider(),
|
||
"anthropic",
|
||
);
|
||
|
||
assert!(changed);
|
||
assert_eq!(body["thinking"]["type"], "disabled");
|
||
assert!(body.get("output_config").is_none());
|
||
}
|
||
}
|