fix: enable automatic decompression for HTTP responses

- Add gzip, brotli, deflate features to reqwest in Cargo.toml
- Enable automatic decompression in ClaudeCustomProvider HTTP client
- Enable automatic decompression in OpenAICustomProvider HTTP client
- Add update_provider_env_vars command for Claude Code environment setup

This fixes garbled response issue when upstream providers return
compressed responses (e.g., anyrouter.top returning gzip-compressed data).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
coso
2026-01-12 16:10:38 +08:00
co-authored by Claude Opus 4.5
parent e2651861b5
commit a52ec0123e
6 changed files with 251 additions and 6 deletions
+35
View File
@@ -144,6 +144,18 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-compression"
version = "0.4.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d10e4f991a553474232bc0a31799f6d24b034a84c0971d80d2e2f78b2e576e40"
dependencies = [
"compression-codecs",
"compression-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "async-executor"
version = "1.13.3"
@@ -805,6 +817,24 @@ dependencies = [
"memchr",
]
[[package]]
name = "compression-codecs"
version = "0.4.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a"
dependencies = [
"brotli",
"compression-core",
"flate2",
"memchr",
]
[[package]]
name = "compression-core"
version = "0.4.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d"
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -5993,13 +6023,18 @@ version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"async-compression",
"bitflags 2.10.0",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"iri-string",
"pin-project-lite",
"tokio",
"tokio-util",
"tower 0.5.2",
"tower-layer",
"tower-service",
+1 -1
View File
@@ -29,7 +29,7 @@ axum-server = { version = "0.7", features = ["tls-rustls"] }
rustls-pemfile = "2"
tower = "0.4"
tower-http = { version = "0.5", features = ["limit", "cors"] }
reqwest = { version = "0.12", features = ["json", "stream"] }
reqwest = { version = "0.12", features = ["json", "stream", "gzip", "brotli", "deflate"] }
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
dirs = "5"
+155
View File
@@ -182,3 +182,158 @@ pub async fn set_endpoint_provider(
);
Ok(provider_display.to_string())
}
/// 根据 API Key Provider 更新环境变量
///
/// 当用户在 API Server 页面选择一个 API Key Provider 时调用
/// 会更新 ~/.claude/settings.json 和 shell 配置文件中的环境变量
#[tauri::command]
pub async fn update_provider_env_vars(
logs: tauri::State<'_, LogState>,
provider_type: String,
api_host: String,
api_key: Option<String>,
) -> Result<(), String> {
use crate::services::live_sync::write_env_to_shell_config;
use serde_json::{json, Value};
use std::fs;
let home = dirs::home_dir().ok_or("Cannot find home directory")?;
// 根据 provider_type 确定要更新的环境变量
// 参考 Claude Code 文档:https://code.claude.com/docs/en/llm-gateway
let env_vars: Vec<(String, String)> = match provider_type.to_lowercase().as_str() {
// Anthropic 兼容类型 - 包括大多数第三方 Provider
// 如 DeepSeek、智谱、MiniMax、OpenRouter、AiHubMix 等
"anthropic" | "new-api" | "gateway" => {
let mut vars = vec![("ANTHROPIC_BASE_URL".to_string(), api_host.clone())];
if let Some(key) = api_key {
vars.push(("ANTHROPIC_AUTH_TOKEN".to_string(), key));
}
vars
}
// OpenAI 兼容类型 - 用于 Codex 等
"openai" | "openai-response" => {
let mut vars = vec![("OPENAI_BASE_URL".to_string(), api_host.clone())];
if let Some(key) = api_key {
vars.push(("OPENAI_API_KEY".to_string(), key));
}
vars
}
// Gemini 类型
"gemini" => {
let mut vars = vec![("GEMINI_API_BASE_URL".to_string(), api_host.clone())];
if let Some(key) = api_key {
vars.push(("GEMINI_API_KEY".to_string(), key));
}
vars
}
// Azure OpenAI 类型
"azure-openai" => {
let mut vars = vec![("AZURE_OPENAI_BASE_URL".to_string(), api_host.clone())];
if let Some(key) = api_key {
vars.push(("AZURE_OPENAI_API_KEY".to_string(), key));
}
vars
}
// Google Vertex AI 类型
"vertexai" => {
let mut vars = vec![("ANTHROPIC_VERTEX_BASE_URL".to_string(), api_host.clone())];
if let Some(key) = api_key {
vars.push(("GOOGLE_APPLICATION_CREDENTIALS".to_string(), key));
}
vars
}
// AWS Bedrock 类型
"aws-bedrock" => {
let vars = vec![
("ANTHROPIC_BEDROCK_BASE_URL".to_string(), api_host.clone()),
("CLAUDE_CODE_USE_BEDROCK".to_string(), "1".to_string()),
];
// Bedrock 通常使用 AWS 凭证,不需要单独的 API Key
vars
}
// Ollama 本地部署
"ollama" => {
let vars = vec![("OLLAMA_BASE_URL".to_string(), api_host.clone())];
vars
}
_ => {
// 未知类型,默认使用 ANTHROPIC_BASE_URL(因为大多数第三方 Provider 都是 Anthropic 兼容的)
logs.write().await.add(
"info",
&format!(
"Provider 类型 '{}' 使用默认 ANTHROPIC_BASE_URL",
provider_type
),
);
let mut vars = vec![("ANTHROPIC_BASE_URL".to_string(), api_host.clone())];
if let Some(key) = api_key {
vars.push(("ANTHROPIC_AUTH_TOKEN".to_string(), key));
}
vars
}
};
// 1. 更新 ~/.claude/settings.json
let claude_dir = home.join(".claude");
let settings_path = claude_dir.join("settings.json");
// 确保目录存在
if !claude_dir.exists() {
fs::create_dir_all(&claude_dir).map_err(|e| e.to_string())?;
}
// 读取现有配置
let mut settings: Value = if settings_path.exists() {
let content = fs::read_to_string(&settings_path).map_err(|e| e.to_string())?;
serde_json::from_str(&content).unwrap_or(json!({}))
} else {
json!({})
};
// 更新 env 字段
let settings_obj = settings.as_object_mut().ok_or("Invalid settings format")?;
if !settings_obj.contains_key("env") {
settings_obj.insert("env".to_string(), json!({}));
}
if let Some(env_obj) = settings_obj.get_mut("env").and_then(|v| v.as_object_mut()) {
for (key, value) in &env_vars {
env_obj.insert(key.clone(), json!(value));
}
}
// 写入配置文件
let content = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
fs::write(&settings_path, content).map_err(|e| e.to_string())?;
// 2. 更新 shell 配置文件
if let Err(e) = write_env_to_shell_config(&env_vars) {
logs.write()
.await
.add("warn", &format!("写入 shell 配置文件失败: {}", e));
// 不中断流程
}
logs.write().await.add(
"info",
&format!(
"已更新 {} 环境变量: {}",
provider_type,
env_vars
.iter()
.map(|(k, _)| k.as_str())
.collect::<Vec<_>>()
.join(", ")
),
);
tracing::info!(
"[CONFIG] Provider 环境变量已更新: type={}, api_host={}",
provider_type,
api_host
);
Ok(())
}
+41 -3
View File
@@ -175,12 +175,40 @@ pub struct PluginUIInfo {
/// 尝试读取 plugin.json 文件并解析为 PluginManifest
fn read_plugin_manifest(install_path: &Path) -> Option<PluginManifest> {
let manifest_path = install_path.join("plugin.json");
tracing::debug!(
"read_plugin_manifest: install_path={:?}, manifest_path={:?}, exists={}",
install_path,
manifest_path,
manifest_path.exists()
);
if !manifest_path.exists() {
tracing::debug!("read_plugin_manifest: manifest file does not exist");
return None;
}
let content = std::fs::read_to_string(&manifest_path).ok()?;
serde_json::from_str(&content).ok()
match std::fs::read_to_string(&manifest_path) {
Ok(content) => {
tracing::debug!(
"read_plugin_manifest: file content length={}",
content.len()
);
match serde_json::from_str(&content) {
Ok(manifest) => {
tracing::debug!("read_plugin_manifest: parsed successfully");
Some(manifest)
}
Err(e) => {
tracing::error!("read_plugin_manifest: JSON parse error: {}", e);
None
}
}
}
Err(e) => {
tracing::error!("read_plugin_manifest: read file error: {}", e);
None
}
}
}
/// 获取带有 UI 配置的已安装插件列表
@@ -322,7 +350,17 @@ pub async fn read_plugin_manifest_cmd(
let plugins_dir = manager.plugins_dir();
let plugin_path = plugins_dir.join(&plugin_id);
Ok(read_plugin_manifest(&plugin_path))
tracing::debug!(
"read_plugin_manifest_cmd: plugin_id={}, plugins_dir={:?}, plugin_path={:?}",
plugin_id,
plugins_dir,
plugin_path
);
let result = read_plugin_manifest(&plugin_path);
tracing::debug!("read_plugin_manifest_cmd: result={:?}", result.is_some());
Ok(result)
}
/// 启动插件 UI(用于 binary 类型插件)
+3
View File
@@ -29,6 +29,9 @@ fn create_http_client() -> Client {
.connect_timeout(Duration::from_secs(30))
.timeout(Duration::from_secs(600)) // 10 分钟总超时,支持长时间流式响应
.tcp_keepalive(Duration::from_secs(60)) // TCP keepalive 保持连接活跃
.gzip(true) // 自动解压 gzip 响应
.brotli(true) // 自动解压 brotli 响应
.deflate(true) // 自动解压 deflate 响应
.build()
.unwrap_or_else(|_| Client::new())
}
+16 -2
View File
@@ -3,6 +3,7 @@ use crate::models::openai::ChatCompletionRequest;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::error::Error;
use std::time::Duration;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct OpenAICustomConfig {
@@ -16,11 +17,24 @@ pub struct OpenAICustomProvider {
pub client: Client,
}
/// 创建配置好的 HTTP 客户端
fn create_http_client() -> Client {
Client::builder()
.connect_timeout(Duration::from_secs(30))
.timeout(Duration::from_secs(600)) // 10 分钟总超时
.tcp_keepalive(Duration::from_secs(60))
.gzip(true) // 自动解压 gzip 响应
.brotli(true) // 自动解压 brotli 响应
.deflate(true) // 自动解压 deflate 响应
.build()
.unwrap_or_else(|_| Client::new())
}
impl Default for OpenAICustomProvider {
fn default() -> Self {
Self {
config: OpenAICustomConfig::default(),
client: Client::new(),
client: create_http_client(),
}
}
}
@@ -38,7 +52,7 @@ impl OpenAICustomProvider {
base_url,
enabled: true,
},
client: Client::new(),
client: create_http_client(),
}
}