use reqwest::RequestBuilder;
use serde_json::json;
use crate::{provider::Provider, proxy::error::ProxyError};
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
pub struct ClaudeAdapter;
pub fn get_claude_api_format(provider: &Provider) -> &'static str {
if let Some(meta) = provider.meta.as_ref() {
if meta.provider_type.as_deref() == Some("codex_oauth") {
return "openai_responses";
}
}
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",
_ => "anthropic",
};
}
}
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",
_ => "anthropic",
};
}
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")
}
fn is_reasoning_content_compatible_identifier(value: &str) -> bool {
let value = value.to_ascii_lowercase();
value.contains("moonshot") || value.contains("kimi") || value.contains("deepseek")
}
fn should_preserve_reasoning_content_for_openai_chat(
provider: &Provider,
body: &serde_json::Value,
) -> bool {
if body
.get("model")
.and_then(|m| m.as_str())
.is_some_and(is_reasoning_content_compatible_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_content_compatible_identifier)
}
pub fn transform_claude_request_for_api_format(
body: serde_json::Value,
provider: &Provider,
api_format: &str,
) -> Result<serde_json::Value, ProxyError> {
let cache_key = provider
.meta
.as_ref()
.and_then(|meta| meta.prompt_cache_key.as_deref())
.unwrap_or(&provider.id);
match api_format {
"openai_responses" => super::transform_responses::anthropic_to_responses(
body,
Some(cache_key),
provider
.meta
.as_ref()
.and_then(|meta| meta.provider_type.as_deref())
== Some("codex_oauth"),
),
"openai_chat" => {
let preserve_reasoning_content =
should_preserve_reasoning_content_for_openai_chat(provider, &body);
if preserve_reasoning_content {
super::transform::anthropic_to_openai_with_reasoning_content(
body,
Some(cache_key),
true,
)
} else {
super::transform::anthropic_to_openai(body, Some(cache_key))
}
}
_ => Ok(body),
}
}
impl ClaudeAdapter {
pub fn new() -> Self {
Self
}
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
if self.is_codex_oauth(provider) {
return ProviderType::CodexOAuth;
}
if self.is_github_copilot(provider) {
return ProviderType::GitHubCopilot;
}
if self.is_openrouter(provider) {
return ProviderType::OpenRouter;
}
if self.is_bearer_only_mode(provider) {
return ProviderType::ClaudeAuth;
}
ProviderType::Claude
}
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
}
fn is_openrouter(&self, provider: &Provider) -> bool {
self.extract_base_url(provider)
.map(|base_url| base_url.contains("openrouter.ai"))
.unwrap_or(false)
}
fn is_github_copilot(&self, provider: &Provider) -> bool {
if let Some(meta) = provider.meta.as_ref() {
if meta.provider_type.as_deref() == Some("github_copilot") {
return true;
}
}
self.extract_base_url(provider)
.map(|base_url| base_url.contains("githubcopilot.com"))
.unwrap_or(false)
}
fn get_api_format(&self, provider: &Provider) -> &'static str {
get_claude_api_format(provider)
}
fn is_bearer_only_mode(&self, provider: &Provider) -> bool {
if let Some(auth_mode) = provider
.settings_config
.get("auth_mode")
.and_then(|v| v.as_str())
{
if auth_mode == "bearer_only" {
return true;
}
}
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
}
fn extract_key(&self, provider: &Provider) -> Option<String> {
if let Some(env) = provider.settings_config.get("env") {
if let Some(key) = env
.get("ANTHROPIC_AUTH_TOKEN")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
return Some(key.to_string());
}
if let Some(key) = env
.get("ANTHROPIC_API_KEY")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
return Some(key.to_string());
}
if let Some(key) = env
.get("OPENROUTER_API_KEY")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
return Some(key.to_string());
}
if let Some(key) = env
.get("OPENAI_API_KEY")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
return Some(key.to_string());
}
}
provider
.settings_config
.get("apiKey")
.or_else(|| provider.settings_config.get("api_key"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
}
}
impl Default for ClaudeAdapter {
fn default() -> Self {
Self::new()
}
}
impl ProviderAdapter for ClaudeAdapter {
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
if self.is_codex_oauth(provider) {
return Ok("https://chatgpt.com/backend-api/codex".to_string());
}
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());
}
}
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);
if provider_type == ProviderType::GitHubCopilot {
return Some(AuthInfo::new(
"copilot_placeholder".to_string(),
AuthStrategy::GitHubCopilot,
));
}
if provider_type == ProviderType::CodexOAuth {
return Some(AuthInfo::new(
"codex_oauth_placeholder".to_string(),
AuthStrategy::CodexOAuth,
));
}
let strategy = match provider_type {
ProviderType::OpenRouter => AuthStrategy::Bearer,
ProviderType::ClaudeAuth => AuthStrategy::ClaudeAuth,
_ => AuthStrategy::Anthropic,
};
self.extract_key(provider)
.map(|key| AuthInfo::new(key, strategy))
}
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
if base_url == "https://chatgpt.com/backend-api/codex" {
let _ = endpoint;
return "https://chatgpt.com/backend-api/codex/responses".to_string();
}
let mut base = format!(
"{}/{}",
base_url.trim_end_matches('/'),
endpoint.trim_start_matches('/')
);
while base.contains("/v1/v1") {
base = base.replace("/v1/v1", "/v1");
}
if endpoint.contains("/v1/messages")
&& !endpoint.contains("/v1/chat/completions")
&& !endpoint.contains('?')
{
format!("{base}?beta=true")
} else {
base
}
}
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
match auth.strategy {
AuthStrategy::Anthropic => request
.header("Authorization", format!("Bearer {}", auth.api_key))
.header("x-api-key", &auth.api_key),
AuthStrategy::ClaudeAuth => {
request.header("Authorization", format!("Bearer {}", auth.api_key))
}
AuthStrategy::GitHubCopilot => request
.header("Authorization", format!("Bearer {}", auth.api_key))
.header("Editor-Version", "vscode/1.85.0")
.header("Editor-Plugin-Version", "copilot/1.150.0")
.header("Copilot-Integration-Id", "vscode-chat"),
AuthStrategy::CodexOAuth => request
.header("Authorization", format!("Bearer {}", auth.api_key))
.header("originator", "cc-switch-tui"),
AuthStrategy::Bearer | AuthStrategy::Google | AuthStrategy::GoogleOAuth => {
request.header("Authorization", format!("Bearer {}", auth.api_key))
}
}
}
fn needs_transform(&self, provider: &Provider) -> bool {
if self.is_codex_oauth(provider) {
return true;
}
if self.is_github_copilot(provider) {
return true;
}
claude_api_format_needs_transform(self.get_api_format(provider))
}
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))
}
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
if body.get("error").is_some()
&& body.get("choices").is_none()
&& body.get("output").is_none()
{
return Ok(openai_error_to_anthropic(body));
}
if body.get("output").is_some() {
super::transform_responses::responses_to_anthropic(body)
} else {
super::transform::openai_to_anthropic(body)
}
}
}
fn openai_error_to_anthropic(body: serde_json::Value) -> serde_json::Value {
let error = body.get("error").cloned().unwrap_or_else(|| json!({}));
let message = error
.get("message")
.and_then(|value| value.as_str())
.unwrap_or("Upstream error");
let error_type = error
.get("type")
.and_then(|value| value.as_str())
.unwrap_or("invalid_request_error");
json!({
"type": "error",
"error": {
"type": error_type,
"message": message
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::Provider;
use serde_json::json;
#[test]
fn provider_meta_provider_type_github_copilot_uses_upstream_runtime_behavior() {
let adapter = ClaudeAdapter::new();
let provider: Provider = serde_json::from_value(json!({
"id": "copilot-meta",
"name": "Copilot Meta",
"settingsConfig": {
"env": {
"ANTHROPIC_BASE_URL": "https://relay.example.com",
"ANTHROPIC_AUTH_TOKEN": "token-1"
}
},
"meta": {
"providerType": "github_copilot"
}
}))
.expect("provider should deserialize");
assert_eq!(
format!("{:?}", adapter.provider_type(&provider)),
"GitHubCopilot"
);
let auth = adapter
.extract_auth(&provider)
.expect("github copilot should resolve auth");
assert_eq!(format!("{:?}", auth.strategy), "GitHubCopilot");
assert!(adapter.needs_transform(&provider));
}
#[test]
fn provider_meta_provider_type_codex_oauth_uses_responses_runtime_behavior() {
let adapter = ClaudeAdapter::new();
let provider: Provider = serde_json::from_value(json!({
"id": "codex-oauth-meta",
"name": "Codex OAuth",
"settingsConfig": {
"env": {
"ANTHROPIC_BASE_URL": "https://relay.example.com",
"ANTHROPIC_AUTH_TOKEN": "token-1"
}
},
"meta": {
"providerType": "codex_oauth"
}
}))
.expect("provider should deserialize");
assert_eq!(get_claude_api_format(&provider), "openai_responses");
assert_eq!(
format!("{:?}", adapter.provider_type(&provider)),
"CodexOAuth"
);
assert_eq!(
adapter
.extract_base_url(&provider)
.expect("codex oauth base url"),
"https://chatgpt.com/backend-api/codex"
);
let auth = adapter
.extract_auth(&provider)
.expect("codex oauth should resolve auth");
assert_eq!(format!("{:?}", auth.strategy), "CodexOAuth");
assert!(adapter.needs_transform(&provider));
}
#[test]
fn openai_chat_transform_preserves_reasoning_content_for_deepseek_model() {
let provider: Provider = serde_json::from_value(json!({
"id": "deepseek",
"name": "DeepSeek",
"settingsConfig": {
"api_format": "openai_chat",
"env": {
"ANTHROPIC_BASE_URL": "https://api.deepseek.com",
"ANTHROPIC_AUTH_TOKEN": "token-1"
}
}
}))
.expect("provider should deserialize");
let body = json!({
"model": "deepseek-v4-pro",
"messages": [{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "I should call the tool."},
{"type": "tool_use", "id": "call_1", "name": "get_weather", "input": {}}
]
}]
});
let result =
transform_claude_request_for_api_format(body, &provider, "openai_chat").unwrap();
assert_eq!(
result["messages"][0]["reasoning_content"],
"I should call the tool."
);
}
#[test]
fn openai_chat_transform_skips_reasoning_content_for_generic_provider() {
let provider: Provider = serde_json::from_value(json!({
"id": "generic",
"name": "Generic",
"settingsConfig": {
"api_format": "openai_chat",
"env": {
"ANTHROPIC_BASE_URL": "https://api.example.com",
"ANTHROPIC_AUTH_TOKEN": "token-1"
}
}
}))
.expect("provider should deserialize");
let body = json!({
"model": "gpt-4o",
"messages": [{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "I should call the tool."},
{"type": "tool_use", "id": "call_1", "name": "get_weather", "input": {}}
]
}]
});
let result =
transform_claude_request_for_api_format(body, &provider, "openai_chat").unwrap();
assert!(result["messages"][0].get("reasoning_content").is_none());
}
}