Merge remote-tracking branch 'origin/main' into feature/managed-oauth-account-selector

This commit is contained in:
Jason
2026-07-01 17:04:54 +08:00
123 changed files with 8439 additions and 2864 deletions
+2 -1
View File
@@ -758,7 +758,7 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.16.3"
version = "3.16.4"
dependencies = [
"anyhow",
"arboard",
@@ -825,6 +825,7 @@ dependencies = [
"windows-sys 0.61.2",
"winreg 0.52.0",
"zip 2.4.2",
"zstd",
]
[[package]]
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.16.3"
version = "3.16.4"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
@@ -43,6 +43,7 @@ reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks
arboard = "3.6"
flate2 = "1"
brotli = "7"
zstd = "0.13"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
futures = "0.3"
async-stream = "0.3"
+544 -25
View File
@@ -15,6 +15,86 @@ use toml_edit::DocumentMut;
pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "custom";
pub const CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME: &str = "cc-switch-model-catalog.json";
/// Top-level `config.toml` key that controls Codex's built-in web-search tool.
const CODEX_WEB_SEARCH_FIELD: &str = "web_search";
/// Value that disables the web-search tool. Some native `/responses` gateways
/// reject a `web_search` tool with `responses_feature_not_supported` ("tool type
/// 'web_search' is not supported by this gateway phase"), so for those we write
/// this per the vendors' official Codex docs. Also doubles as cc-switch's
/// ownership sentinel: we only ever remove a `web_search` key whose value equals
/// this string, never a user's own setting.
const CODEX_WEB_SEARCH_DISABLED: &str = "disabled";
/// Native `/responses` gateways whose first-party models do NOT support the Codex
/// `web_search` hosted tool. A BLACKLIST (default-on): everything not listed keeps
/// Codex's default, so relays/aggregators fronting real GPT — and any unknown
/// provider — are never touched. This avoids a whitelist's dangerous failure mode
/// (a fragile "is this GPT?" heuristic wrongly keeping web_search ON → hard 400);
/// the blacklist's failure mode is the safe, recoverable one (a not-yet-listed
/// broken gateway errors once → add it here).
///
/// Matched two ways so an aggregator (e.g. SiliconFlow) fronting these vendors'
/// models is also caught:
/// - `base_url` host substring, and
/// - the model id's brand prefix (after stripping any `vendor/` path segment).
///
/// Verified 2026-06-28 doc audit — reject: MiMo (hard 400), LongCat (official
/// config ships `web_search = "disabled"`), MiniMax (tool-type enum `['function']`
/// only), and Qwen3-Coder models (百炼 marks built-in tools unsupported for
/// the coder series). Deliberately NOT listed by host: 火山方舟豆包, general
/// 阿里百炼 Qwen models that support built-in web_search, and GPT-native relays.
const CODEX_WEB_SEARCH_REJECT_HOSTS: &[&str] = &[
"xiaomimimo.com", // Xiaomi MiMo (api.xiaomimimo.com, token-plan-cn.xiaomimimo.com)
"longcat.chat", // Meituan LongCat (api.longcat.chat)
"minimax.io", // MiniMax global (api.minimax.io)
"minimaxi.com", // MiniMax CN (api.minimaxi.com)
];
/// Brand prefixes of models whose native gateways reject `web_search`, matched
/// against the model id's last `/`-segment so aggregator ids like
/// `MiniMaxAI/MiniMax-M3` are caught. Exact brand names (not a fuzzy heuristic),
/// so a supporting gateway is never wrongly matched.
const CODEX_WEB_SEARCH_REJECT_MODEL_PREFIXES: &[&str] =
&["mimo", "longcat", "minimax", "qwen3-coder"];
/// Top-level `model` id from a Codex `config.toml`.
fn codex_top_level_model(config_text: &str) -> Option<String> {
let doc = config_text.parse::<toml::Value>().ok()?;
doc.get("model")
.and_then(|value| value.as_str())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
/// Whether a native `/responses` provider's gateway is known to reject the Codex
/// `web_search` hosted tool — by `base_url` host OR by the active model's brand
/// (so an aggregator fronting a reject vendor's model is caught too). Driven by
/// the live `config.toml`, so it applies to existing providers without a re-save.
fn codex_native_gateway_rejects_web_search(config_text: &str) -> bool {
if let Some(base_url) = extract_codex_base_url(config_text) {
let base_url = base_url.to_ascii_lowercase();
if CODEX_WEB_SEARCH_REJECT_HOSTS
.iter()
.any(|host| base_url.contains(host))
{
return true;
}
}
if let Some(model) = codex_top_level_model(config_text) {
let model = model.to_ascii_lowercase();
// Strip any aggregator "vendor/" prefix, e.g. "MiniMaxAI/MiniMax-M3"
// or "qwen/qwen3-coder-plus".
let model = model.rsplit('/').next().unwrap_or(model.as_str());
if CODEX_WEB_SEARCH_REJECT_MODEL_PREFIXES
.iter()
.any(|prefix| model.starts_with(prefix))
{
return true;
}
}
false
}
const CODEX_MODEL_CATALOG_TEMPLATE_SLUG: &str = "gpt-5.5";
const CODEX_MANAGED_OAUTH_LIVE_AUTH_MARKER_FILENAME: &str = "codex_managed_oauth_live_auth.json";
@@ -25,6 +105,33 @@ struct CodexManagedOAuthLiveAuthMarker {
access_token_sha256: String,
}
/// Which Codex tool surface the generated model catalog should target.
///
/// - `ProxyChat`: cc-switch's proxy takes over and converts Responses<->Chat,
/// so the catalog keeps Codex's default tool set (incl. the freeform
/// `apply_patch` custom tool, which the proxy rewrites to a function tool).
/// - `NativeResponses`: Codex talks directly to a provider's native
/// `/responses` endpoint (no proxy). Such gateways (e.g. Xiaomi MiMo,
/// MiniMax) reject `type=="custom"` tools, so the catalog must suppress the
/// freeform `apply_patch` and rely on `shell_type="shell_command"` for edits.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodexCatalogToolProfile {
ProxyChat,
NativeResponses,
}
impl CodexCatalogToolProfile {
/// Pick the catalog tool profile from a provider's `apiFormat` meta value.
/// Native (direct) Responses providers must suppress the custom apply_patch
/// tool; everything else keeps the proxy-chat behavior.
pub fn from_api_format(api_format: Option<&str>) -> Self {
match api_format {
Some("openai_responses") => CodexCatalogToolProfile::NativeResponses,
_ => CodexCatalogToolProfile::ProxyChat,
}
}
}
/// Reserved built-in provider IDs from OpenAI Codex's config/model-provider
/// catalog. Keep in sync with Codex `RESERVED_MODEL_PROVIDER_IDS` and legacy
/// removed provider aliases.
@@ -440,27 +547,63 @@ fn extract_codex_top_level_u64(config_text: &str, field: &str) -> Option<u64> {
fn codex_catalog_model_entry(
template: &Value,
model: &str,
display_name: &str,
context_window: u64,
spec: &CodexCatalogModelSpec,
priority: usize,
profile: CodexCatalogToolProfile,
) -> Value {
let mut entry = template.clone();
let Some(entry_obj) = entry.as_object_mut() else {
return json!({});
};
entry_obj.insert("slug".to_string(), json!(model));
entry_obj.insert("display_name".to_string(), json!(display_name));
entry_obj.insert("description".to_string(), json!(display_name));
entry_obj.insert("context_window".to_string(), json!(context_window));
entry_obj.insert("max_context_window".to_string(), json!(context_window));
entry_obj.insert("slug".to_string(), json!(spec.model));
entry_obj.insert("display_name".to_string(), json!(spec.display_name));
entry_obj.insert("description".to_string(), json!(spec.display_name));
entry_obj.insert("context_window".to_string(), json!(spec.context_window));
entry_obj.insert("max_context_window".to_string(), json!(spec.context_window));
entry_obj.insert("priority".to_string(), json!(1000 + priority));
entry_obj.insert("additional_speed_tiers".to_string(), json!([]));
entry_obj.insert("service_tiers".to_string(), json!([]));
entry_obj.insert("availability_nux".to_string(), Value::Null);
entry_obj.insert("upgrade".to_string(), Value::Null);
if profile == CodexCatalogToolProfile::NativeResponses {
// Native `/responses` gateways reject Codex's freeform `apply_patch`
// (type=="custom") tool. Strip any key that would make Codex emit a
// custom/freeform tool, and rely on shell_type="shell_command" for
// edits. Defensive even though the native template is already clean
// (guards against template drift / an accidental gpt-5.5 clone).
//
// NOTE: `base_instructions` is NOT stripped — Codex's catalog parser
// treats it as a REQUIRED field and refuses to load the file without
// it ("missing field `base_instructions`"). The template carries a
// neutral identity default; per-vendor official text overrides below.
for key in [
"apply_patch_tool_type",
"web_search_tool_type",
"tools",
"model_messages",
] {
entry_obj.remove(key);
}
entry_obj.insert("shell_type".to_string(), json!("shell_command"));
if let Some(base_instructions) = spec
.base_instructions
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
entry_obj.insert("base_instructions".to_string(), json!(base_instructions));
}
if let Some(parallel) = spec.supports_parallel_tool_calls {
entry_obj.insert("supports_parallel_tool_calls".to_string(), json!(parallel));
}
if let Some(modalities) = &spec.input_modalities {
entry_obj.insert("input_modalities".to_string(), json!(modalities));
}
}
entry
}
@@ -469,6 +612,18 @@ struct CodexCatalogModelSpec {
model: String,
display_name: String,
context_window: u64,
/// Per-row override for the native template's `supports_parallel_tool_calls`
/// (e.g. MiniMax=true, MiMo=false). Only consulted for `NativeResponses`.
supports_parallel_tool_calls: Option<bool>,
/// Per-row override for the native template's `input_modalities`
/// (e.g. `["text","image"]`). Only consulted for `NativeResponses`.
input_modalities: Option<Vec<String>>,
/// Per-row override for the native template's `base_instructions` (the
/// model identity / system preamble). Carries each vendor's OFFICIAL value
/// (e.g. MiMo "developed by Xiaomi", MiniMax "based on MiniMax-M3"); falls
/// back to the template default when absent. Only consulted for
/// `NativeResponses`.
base_instructions: Option<String>,
}
fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec<CodexCatalogModelSpec> {
@@ -513,10 +668,38 @@ fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec<CodexCa
)
.unwrap_or(default_context_window);
let supports_parallel_tool_calls = model_config
.get("supportsParallelToolCalls")
.or_else(|| model_config.get("supports_parallel_tool_calls"))
.and_then(|value| value.as_bool());
let input_modalities = model_config
.get("inputModalities")
.or_else(|| model_config.get("input_modalities"))
.and_then(|value| value.as_array())
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str())
.map(str::to_string)
.collect::<Vec<_>>()
})
.filter(|items| !items.is_empty());
let base_instructions = model_config
.get("baseInstructions")
.or_else(|| model_config.get("base_instructions"))
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|text| !text.is_empty())
.map(str::to_string);
specs.push(CodexCatalogModelSpec {
model: model.to_string(),
display_name: display_name.to_string(),
context_window,
supports_parallel_tool_calls,
input_modalities,
base_instructions,
});
}
@@ -755,6 +938,18 @@ fn load_codex_model_template_static() -> Option<Value> {
}
}
/// Bundled clean template for native `/responses` providers. Unlike the
/// gpt-5.5 template it carries NO freeform `apply_patch` / `web_search` tool
/// declarations and no GPT-5 base_instructions, so Codex never emits a
/// `type=="custom"` tool that native gateways (MiMo/MiniMax/…) reject. Edits
/// flow through `shell_type="shell_command"` instead. We deliberately do NOT
/// fall back to `models_cache.json` here (that would reintroduce gpt-5.5's
/// freeform apply_patch).
fn load_codex_native_responses_template() -> Value {
let text = include_str!("resources/codex_native_responses_template.json");
serde_json::from_str(text).expect("bundled codex native responses template must be valid JSON")
}
fn load_codex_model_catalog_template() -> Result<Value, AppError> {
// ① models_cache.json (created by Codex when it connects to OpenAI)
if let Some(template) = load_codex_model_template_from_cache()? {
@@ -774,19 +969,15 @@ fn load_codex_model_catalog_template() -> Result<Value, AppError> {
)))
}
fn codex_model_catalog_from_specs(specs: &[CodexCatalogModelSpec], template: &Value) -> Value {
fn codex_model_catalog_from_specs(
specs: &[CodexCatalogModelSpec],
template: &Value,
profile: CodexCatalogToolProfile,
) -> Value {
let entries: Vec<Value> = specs
.iter()
.enumerate()
.map(|(index, spec)| {
codex_catalog_model_entry(
template,
&spec.model,
&spec.display_name,
spec.context_window,
index,
)
})
.map(|(index, spec)| codex_catalog_model_entry(template, spec, index, profile))
.collect();
json!({ "models": entries })
@@ -795,14 +986,23 @@ fn codex_model_catalog_from_specs(specs: &[CodexCatalogModelSpec], template: &Va
fn codex_model_catalog_from_settings(
settings: &Value,
config_text: &str,
profile: CodexCatalogToolProfile,
) -> Result<Option<Value>, AppError> {
let specs = codex_catalog_model_specs(settings, config_text);
if specs.is_empty() {
return Ok(None);
}
let template = load_codex_model_catalog_template()?;
Ok(Some(codex_model_catalog_from_specs(&specs, &template)))
// Native providers use the bundled clean template (no freeform apply_patch,
// no cache dependency); proxy-chat providers keep cloning Codex's gpt-5.5
// entry so the proxy can rewrite custom<->function tools as before.
let template = match profile {
CodexCatalogToolProfile::NativeResponses => load_codex_native_responses_template(),
CodexCatalogToolProfile::ProxyChat => load_codex_model_catalog_template()?,
};
Ok(Some(codex_model_catalog_from_specs(
&specs, &template, profile,
)))
}
fn set_codex_model_catalog_json_field(
@@ -835,20 +1035,60 @@ fn set_codex_model_catalog_json_field(
Ok(doc.to_string())
}
/// Pure toggle for the top-level `web_search` field that turns Codex's built-in
/// web-search tool off. When `disable` is true we write `web_search = "disabled"`
/// (the catalog's `supports_search_tool` does NOT gate this — the request-time
/// tool comes from the config, defaulting on). When false we *remove* the field,
/// but only when it carries cc-switch's own `"disabled"` sentinel, so switching
/// back to a web-search-capable provider re-enables it without clobbering a
/// user's manual setting.
///
/// The caller decides `disable` (see `codex_native_gateway_rejects_web_search`);
/// lifecycle is bound to the cc-switch catalog pointer so the field is set/cleaned
/// up wherever the native catalog is written/removed.
fn set_codex_native_web_search_field(config_text: &str, disable: bool) -> Result<String, AppError> {
let mut doc = config_text
.parse::<DocumentMut>()
.map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
if disable {
doc[CODEX_WEB_SEARCH_FIELD] = toml_edit::value(CODEX_WEB_SEARCH_DISABLED);
} else {
let owned = doc
.get(CODEX_WEB_SEARCH_FIELD)
.and_then(|item| item.as_str())
== Some(CODEX_WEB_SEARCH_DISABLED);
if owned {
doc.as_table_mut().remove(CODEX_WEB_SEARCH_FIELD);
}
}
Ok(doc.to_string())
}
/// Generate Codex `model_catalog_json` from provider settings and inject/remove
/// the top-level TOML field that points Codex to the generated file.
pub fn prepare_codex_config_text_with_model_catalog(
settings: &Value,
config_text: &str,
profile: CodexCatalogToolProfile,
) -> Result<String, AppError> {
let catalog_path = get_codex_model_catalog_path();
if let Some(catalog) = codex_model_catalog_from_settings(settings, config_text)? {
if let Some(catalog) = codex_model_catalog_from_settings(settings, config_text, profile)? {
let config_text = set_codex_model_catalog_json_field(config_text, Some(&catalog_path))?;
// Disable web_search only for native gateways on the reject blacklist
// (MiMo/LongCat/MiniMax by host or model brand; Qwen3-Coder by model).
// Everything else — relays, DouBao, web-search-capable Qwen models,
// unknown providers — keeps Codex's default.
let disable_web_search = profile == CodexCatalogToolProfile::NativeResponses
&& codex_native_gateway_rejects_web_search(&config_text);
let config_text = set_codex_native_web_search_field(&config_text, disable_web_search)?;
write_json_file(&catalog_path, &catalog)?;
Ok(config_text)
} else {
set_codex_model_catalog_json_field(config_text, None)
let config_text = set_codex_model_catalog_json_field(config_text, None)?;
set_codex_native_web_search_field(&config_text, false)
}
}
@@ -966,6 +1206,26 @@ fn build_simplified_catalog_from_texts(config_text: &str, catalog_text: &str) ->
obj.insert("contextWindow".to_string(), json!(context_window));
}
// Preserve native-profile per-row overrides so a DB-SSOT-missing
// fallback round-trip doesn't silently drop them (they are ignored by
// the ProxyChat profile, so carrying them is harmless).
if let Some(parallel) = entry
.get("supports_parallel_tool_calls")
.and_then(|v| v.as_bool())
{
obj.insert("supportsParallelToolCalls".to_string(), json!(parallel));
}
if let Some(modalities) = entry.get("input_modalities").and_then(|v| v.as_array()) {
let mods: Vec<String> = modalities
.iter()
.filter_map(|m| m.as_str())
.map(str::to_string)
.collect();
if !mods.is_empty() {
obj.insert("inputModalities".to_string(), json!(mods));
}
}
entries.push(Value::Object(obj));
}
@@ -1003,9 +1263,10 @@ fn build_simplified_catalog_from_texts(config_text: &str, catalog_text: &str) ->
pub fn prepare_codex_live_config_text_with_optional_catalog(
settings: &Value,
config_text: &str,
profile: CodexCatalogToolProfile,
) -> Result<String, AppError> {
if settings.get("modelCatalog").is_some() {
prepare_codex_config_text_with_model_catalog(settings, config_text)
prepare_codex_config_text_with_model_catalog(settings, config_text, profile)
} else {
Ok(config_text.to_string())
}
@@ -1016,9 +1277,10 @@ pub fn write_codex_provider_live_with_catalog(
category: Option<&str>,
auth: &Value,
config_text: Option<&str>,
profile: CodexCatalogToolProfile,
) -> Result<(), AppError> {
let prepared_config = config_text
.map(|text| prepare_codex_config_text_with_model_catalog(settings, text))
.map(|text| prepare_codex_config_text_with_model_catalog(settings, text, profile))
.transpose()?;
write_codex_live_for_provider(category, auth, prepared_config.as_deref())
@@ -2310,7 +2572,8 @@ base_url = "https://production.api/v1"
}
});
let specs = codex_catalog_model_specs(&settings, r#"model_context_window = 128000"#);
let catalog = codex_model_catalog_from_specs(&specs, &template);
let catalog =
codex_model_catalog_from_specs(&specs, &template, CodexCatalogToolProfile::ProxyChat);
let models = catalog
.get("models")
.and_then(|value| value.as_array())
@@ -2361,6 +2624,137 @@ base_url = "https://production.api/v1"
);
}
#[test]
fn native_responses_profile_suppresses_apply_patch_and_keeps_shell() {
// Native (direct) /responses providers must NOT emit a freeform
// apply_patch (type=="custom") tool — gateways like MiMo reject it.
// The native profile uses the bundled clean template and relies on
// shell_type="shell_command" for edits, plus per-row overrides.
let settings = json!({
"modelCatalog": {
"models": [
{
"model": "MiniMax-M3",
"displayName": "MiniMax-M3",
"contextWindow": 1_000_000,
"supportsParallelToolCalls": true,
"inputModalities": ["text", "image"],
"baseInstructions": "You are Codex, a coding agent based on MiniMax-M3."
}
]
}
});
let catalog = codex_model_catalog_from_settings(
&settings,
"",
CodexCatalogToolProfile::NativeResponses,
)
.expect("native catalog generation should not error")
.expect("non-empty modelCatalog must yield a catalog");
let entry = &catalog["models"][0];
assert_eq!(
entry.get("slug").and_then(|v| v.as_str()),
Some("MiniMax-M3")
);
assert_eq!(
entry.get("shell_type").and_then(|v| v.as_str()),
Some("shell_command"),
"native entries edit via shell, not the custom apply_patch tool"
);
assert!(
entry.get("apply_patch_tool_type").is_none(),
"native entries must NOT declare a freeform apply_patch tool"
);
// `base_instructions` is REQUIRED by Codex's catalog parser, so it must
// be present — and the per-row official override must win over the
// template default.
assert_eq!(
entry.get("base_instructions").and_then(|v| v.as_str()),
Some("You are Codex, a coding agent based on MiniMax-M3."),
"per-row baseInstructions override must apply (and field must exist)"
);
assert!(
entry.get("model_messages").is_none(),
"native entries must not carry the gpt-5.5 model_messages persona text"
);
assert_eq!(
entry.get("supports_parallel_tool_calls"),
Some(&json!(true)),
"per-row supportsParallelToolCalls override must apply"
);
assert_eq!(
entry.get("input_modalities"),
Some(&json!(["text", "image"])),
"per-row inputModalities override must apply"
);
assert_eq!(
entry.get("context_window").and_then(|v| v.as_u64()),
Some(1_000_000)
);
}
#[test]
fn native_responses_catalog_always_carries_base_instructions() {
// Regression guard for the "missing field `base_instructions`" parse
// error: Codex refuses to load a model catalog whose entries lack
// base_instructions. Synthesized presets carry no per-row override, so
// the entry MUST inherit the template's neutral default rather than
// dropping the field entirely.
let settings = json!({
"modelCatalog": { "models": [{ "model": "qwen3-coder-plus" }] }
});
let catalog = codex_model_catalog_from_settings(
&settings,
"",
CodexCatalogToolProfile::NativeResponses,
)
.expect("native catalog generation should not error")
.expect("non-empty modelCatalog must yield a catalog");
let base = catalog["models"][0]
.get("base_instructions")
.and_then(|v| v.as_str());
assert!(
base.is_some_and(|s| !s.trim().is_empty()),
"every native entry must carry a non-empty base_instructions (Codex requires it)"
);
}
#[test]
fn proxy_chat_profile_still_keeps_apply_patch() {
// Regression guard for Mode A: the proxy-chat profile must keep the
// freeform apply_patch tool (the proxy rewrites custom<->function).
let template = load_codex_native_responses_template();
let specs = vec![CodexCatalogModelSpec {
model: "x".to_string(),
display_name: "x".to_string(),
context_window: 128_000,
supports_parallel_tool_calls: None,
input_modalities: None,
base_instructions: None,
}];
// Using a gpt-5.5-shaped template under ProxyChat must NOT strip
// apply_patch_tool_type. (The native template lacks it, so synthesize
// one with the field present to prove ProxyChat leaves it intact.)
let mut proxy_template = template.clone();
proxy_template["apply_patch_tool_type"] = json!("freeform");
let catalog = codex_model_catalog_from_specs(
&specs,
&proxy_template,
CodexCatalogToolProfile::ProxyChat,
);
assert_eq!(
catalog["models"][0]
.get("apply_patch_tool_type")
.and_then(|v| v.as_str()),
Some("freeform"),
"ProxyChat must preserve apply_patch_tool_type (no native stripping)"
);
}
#[test]
fn model_catalog_json_field_writes_relative_filename() {
let input = r#"model_provider = "any"
@@ -2388,6 +2782,131 @@ name = "any"
);
}
#[test]
fn native_web_search_field_disables_at_top_level() {
// Native `/responses` gateways reject the web_search tool, so the
// NativeResponses profile must write the top-level disable line even
// when sections are present (it must NOT land inside a section).
let input = r#"model_provider = "custom"
[model_providers.custom]
name = "xiaomi_mimo"
"#;
let result = set_codex_native_web_search_field(input, true).unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
assert_eq!(
parsed.get("web_search").and_then(|value| value.as_str()),
Some("disabled")
);
assert!(
parsed
.get("model_providers")
.and_then(|value| value.get("custom"))
.and_then(|value| value.get("web_search"))
.is_none(),
"web_search should stay top-level"
);
}
#[test]
fn native_web_search_field_removes_own_sentinel_when_not_disabled() {
// Switching away from a native provider must re-enable web search by
// removing cc-switch's own "disabled" sentinel.
let input = r#"model = "gpt-5.5"
web_search = "disabled"
"#;
let result = set_codex_native_web_search_field(input, false).unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
assert!(
parsed.get("web_search").is_none(),
"cc-switch's disabled sentinel should be removed when not native"
);
}
#[test]
fn native_web_search_field_preserves_user_value() {
// A user's own web_search value must never be clobbered by cleanup,
// only cc-switch's "disabled" sentinel is owned/removable.
let input = r#"web_search = "enabled"
"#;
let result = set_codex_native_web_search_field(input, false).unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
assert_eq!(
parsed.get("web_search").and_then(|value| value.as_str()),
Some("enabled"),
"a user-set web_search value must be preserved"
);
}
#[test]
fn web_search_blacklist_disables_only_known_reject_gateways() {
let cfg = |model: &str, base_url: &str| {
format!(
"model_provider = \"custom\"\nmodel = \"{model}\"\n\n[model_providers.custom]\nname = \"x\"\nbase_url = \"{base_url}\"\nwire_api = \"responses\"\n"
)
};
// Blacklisted by host (first-party reject gateways) → disable.
for (model, host) in [
("mimo-v2.5-pro", "https://api.xiaomimimo.com/v1"),
("mimo-v2.5", "https://token-plan-cn.xiaomimimo.com/v1"),
("LongCat-2.0-Preview", "https://api.longcat.chat/openai/v1"),
("MiniMax-M3", "https://api.minimax.io/v1"),
("MiniMax-M3", "https://api.minimaxi.com/v1"),
] {
assert!(
codex_native_gateway_rejects_web_search(&cfg(model, host)),
"{host} should be blacklisted"
);
}
// Blacklisted by MODEL brand even on an aggregator host (SiliconFlow
// fronting a reject vendor's model) → disable.
for (model, host) in [
("MiniMax-M3", "https://api.siliconflow.cn/v1"),
("MiniMaxAI/MiniMax-M3", "https://api.siliconflow.cn/v1"),
("mimo-v2.5-pro", "https://some-aggregator.example/v1"),
(
"qwen/qwen3-coder-plus",
"https://some-aggregator.example/v1",
),
] {
assert!(
codex_native_gateway_rejects_web_search(&cfg(model, host)),
"{model} @ {host} should be blacklisted by model brand"
);
}
// Qwen3-Coder is blacklisted by model, not by DashScope host. This keeps
// general Qwen models that support built-in web_search on the same host
// enabled while protecting the native qwen3-coder-plus preset.
assert!(codex_native_gateway_rejects_web_search(&cfg(
"qwen3-coder-plus",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
)));
assert!(!codex_native_gateway_rejects_web_search(&cfg(
"qwen3.7-plus",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
)));
// NOT blacklisted → keep Codex default (relays/GPT, DouBao, general Qwen,
// and any unknown provider incl. an aggregator serving a non-reject model).
for (model, host) in [
("gpt-5.5", "https://www.packyapi.com/v1"),
("gpt-5-codex", "https://aihubmix.com/v1"),
(
"doubao-seed-2-1-pro-260628",
"https://ark.cn-beijing.volces.com/api/v3",
),
("Pro/moonshotai/Kimi-K2.6", "https://api.siliconflow.cn/v1"),
] {
assert!(
!codex_native_gateway_rejects_web_search(&cfg(model, host)),
"{model} @ {host} should NOT be blacklisted"
);
}
}
#[test]
fn resolve_catalog_path_returns_none_when_config_missing_field() {
let generated = PathBuf::from("/tmp/.codex/cc-switch-model-catalog.json");
+52 -4
View File
@@ -1445,11 +1445,15 @@ fn opencode_extra_search_paths(
fn tool_executable_candidates(tool: &str, dir: &Path) -> Vec<std::path::PathBuf> {
#[cfg(target_os = "windows")]
{
vec![
let extensionless = dir.join(tool);
let mut candidates = vec![
dir.join(format!("{tool}.cmd")),
dir.join(format!("{tool}.exe")),
dir.join(tool),
]
];
if windows_runnable_sibling_for_extensionless_tool(&extensionless).is_none() {
candidates.push(extensionless);
}
candidates
}
#[cfg(not(target_os = "windows"))]
@@ -1619,6 +1623,18 @@ fn is_windows_command_script(path: &Path) -> bool {
.unwrap_or(false)
}
#[cfg(target_os = "windows")]
fn windows_runnable_sibling_for_extensionless_tool(path: &Path) -> Option<std::path::PathBuf> {
if path.extension().is_some() {
return None;
}
["cmd", "exe"]
.iter()
.map(|ext| path.with_extension(ext))
.find(|candidate| candidate.is_file())
}
#[cfg(target_os = "windows")]
fn run_windows_tool_version_command(
tool_path: &Path,
@@ -1821,7 +1837,10 @@ fn resolve_path_default(tool: &str) -> Option<std::path::PathBuf> {
if first.is_empty() {
return None;
}
std::fs::canonicalize(first).ok()
let path = Path::new(first);
let preferred =
windows_runnable_sibling_for_extensionless_tool(path).unwrap_or_else(|| path.to_path_buf());
std::fs::canonicalize(preferred).ok()
}
/// 枚举工具在系统中的所有安装(不短路)。与 `scan_cli_version` 共用
@@ -5063,6 +5082,35 @@ mod tests {
);
}
#[cfg(target_os = "windows")]
#[test]
fn tool_executable_candidates_windows_skips_shadowed_npm_unix_shim() {
let dir = tempfile::tempdir().expect("temp dir should be created");
let extensionless = dir.path().join("codex");
let cmd = dir.path().join("codex.cmd");
std::fs::write(&extensionless, "").expect("extensionless shim should be created");
std::fs::write(&cmd, "").expect("cmd shim should be created");
let candidates = tool_executable_candidates("codex", dir.path());
assert_eq!(candidates, vec![cmd.clone(), dir.path().join("codex.exe")]);
assert!(!candidates.contains(&extensionless));
}
#[cfg(target_os = "windows")]
#[test]
fn windows_runnable_sibling_prefers_cmd_over_extensionless_tool() {
let dir = tempfile::tempdir().expect("temp dir should be created");
let extensionless = dir.path().join("codex");
let cmd = dir.path().join("codex.cmd");
std::fs::write(&extensionless, "").expect("extensionless shim should be created");
std::fs::write(&cmd, "").expect("cmd shim should be created");
let preferred = windows_runnable_sibling_for_extensionless_tool(&extensionless);
assert_eq!(preferred.as_deref(), Some(cmd.as_path()));
}
#[test]
fn resolve_launch_cwd_accepts_existing_directory() {
let resolved =
+41 -2
View File
@@ -1,8 +1,15 @@
#![allow(non_snake_case)]
use tauri::AppHandle;
use tauri::{AppHandle, Emitter};
use tauri_plugin_updater::UpdaterExt;
/// 应用更新下载进度(通过 `update-download-progress` 事件发给前端)。
#[derive(Clone, serde::Serialize)]
struct UpdateDownloadProgress {
downloaded: u64,
total: Option<u64>,
}
fn merge_settings_for_save(
mut incoming: crate::settings::AppSettings,
existing: &crate::settings::AppSettings,
@@ -203,8 +210,22 @@ pub async fn install_update_and_restart(app: AppHandle) -> Result<bool, String>
};
log::info!("开始下载应用更新: {}", update.version);
let progress_handle = app.clone();
let mut downloaded: u64 = 0;
let bytes = update
.download(|_, _| {}, || {})
.download(
move |chunk_len, content_len| {
downloaded = downloaded.saturating_add(chunk_len as u64);
let _ = progress_handle.emit(
"update-download-progress",
UpdateDownloadProgress {
downloaded,
total: content_len,
},
);
},
|| {},
)
.await
.map_err(|e| format!("下载更新失败: {e}"))?;
@@ -245,6 +266,24 @@ pub async fn install_update_and_restart(app: AppHandle) -> Result<bool, String>
}
}
/// 检查是否有可用的应用更新,返回可用的新版本号(无更新时返回 None)。
///
/// 数据库版本过新的恢复界面用它判断:升级应用能否解决问题。若返回 None,说明
/// 已是最新版本,但数据库仍不兼容(通常由第三方客户端或更高版本创建),应提示用户
/// 升级无法解决,而不是让其反复尝试。
#[tauri::command]
pub async fn check_app_update_available(app: AppHandle) -> Result<Option<String>, String> {
let updater = app
.updater_builder()
.build()
.map_err(|e| format!("初始化更新器失败: {e}"))?;
let update = updater
.check()
.await
.map_err(|e| format!("检查更新失败: {e}"))?;
Ok(update.map(|u| u.version))
}
/// 获取 app_config_dir 覆盖配置 (从 Store)
#[tauri::command]
pub async fn get_app_config_dir_override(app: AppHandle) -> Result<Option<String>, String> {
+16
View File
@@ -159,6 +159,22 @@ impl Database {
Ok(db)
}
/// 读取磁盘上数据库的 `user_version`;仅当它比应用支持的 [`SCHEMA_VERSION`]
/// 更新时返回 `Some(version)`。
///
/// 用于初始化失败后判断是否为「数据库版本过新(应用过旧,需升级应用)」的可恢复
/// 场景——此时不应反复弹出无效的重试对话框,而应引导用户在应用内升级。
pub fn stored_user_version_exceeds_supported(
db_path: &std::path::Path,
) -> Result<Option<i32>, AppError> {
if !db_path.exists() {
return Ok(None);
}
let conn = Connection::open(db_path).map_err(|e| AppError::Database(e.to_string()))?;
let version = Self::get_user_version(&conn)?;
Ok((version > SCHEMA_VERSION).then_some(version))
}
/// 创建内存数据库(用于测试)
pub fn memory() -> Result<Self, AppError> {
let conn = Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?;
+20
View File
@@ -1684,6 +1684,26 @@ impl Database {
),
// ====== 国产模型 (USD/1M tokens) ======
// Doubao (字节跳动)
// Seed 2.1 系列(2026-06 火山引擎官方 list 价,CNY 按 ~7.14 折算):
// pro 输入 6 元 / 输出 30 元 / 命中 1.2 元
// turbo 输入 3 元 / 输出 15 元 / 命中 0.6 元
// 「缓存存储 0.017 元/M/小时」是按时长计费的存储费,与本表 cache_creation(按 token 写入价)口径不同,置 0。
(
"doubao-seed-2-1-pro",
"Doubao Seed 2.1 Pro",
"0.84",
"4.2",
"0.17",
"0",
),
(
"doubao-seed-2-1-turbo",
"Doubao Seed 2.1 Turbo",
"0.42",
"2.1",
"0.08",
"0",
),
(
"doubao-seed-code",
"Doubao Seed Code",
+44 -14
View File
@@ -185,6 +185,48 @@ fn get_primary_endpoint(request: &DeepLinkImportRequest) -> String {
.unwrap_or_default()
}
fn normalize_deeplink_api_key(api_key: &str) -> String {
api_key.trim().to_string()
}
fn normalize_deeplink_base_url(base_url: &str) -> String {
base_url.trim().trim_end_matches('/').to_string()
}
fn usage_api_key_override(request: &DeepLinkImportRequest) -> Option<String> {
let usage_api_key = normalize_deeplink_api_key(request.usage_api_key.as_deref()?);
if usage_api_key.is_empty() {
return None;
}
let provider_api_key = request
.api_key
.as_deref()
.map(normalize_deeplink_api_key)
.unwrap_or_default();
if !provider_api_key.is_empty() && usage_api_key == provider_api_key {
None
} else {
Some(usage_api_key)
}
}
fn usage_base_url_override(request: &DeepLinkImportRequest) -> Option<String> {
let usage_base_url = normalize_deeplink_base_url(request.usage_base_url.as_deref()?);
if usage_base_url.is_empty() {
return None;
}
let provider_base_url = normalize_deeplink_base_url(&get_primary_endpoint(request));
if !provider_base_url.is_empty() && usage_base_url == provider_base_url {
None
} else {
Some(usage_base_url)
}
}
/// Build provider meta with usage script configuration
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
// Check if any usage script fields are provided
@@ -211,25 +253,13 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
// Determine enabled state: explicit param > has code > false
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
// Build UsageScript - use provider's API key and endpoint as defaults
// Note: use primary endpoint only (first one if comma-separated)
let usage_script = UsageScript {
enabled,
language: "javascript".to_string(),
code,
timeout: Some(10),
api_key: request
.usage_api_key
.clone()
.or_else(|| request.api_key.clone()),
base_url: request.usage_base_url.clone().or_else(|| {
let primary = get_primary_endpoint(request);
if primary.is_empty() {
None
} else {
Some(primary)
}
}),
api_key: usage_api_key_override(request),
base_url: usage_base_url_override(request),
access_token: request.usage_access_token.clone(),
user_id: request.usage_user_id.clone(),
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
+148
View File
@@ -260,6 +260,154 @@ fn test_build_gemini_provider_without_model() {
assert!(env.get("GEMINI_MODEL").is_none());
}
#[test]
fn test_deeplink_usage_script_does_not_copy_provider_credentials() {
use super::provider::build_provider_from_request;
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("claude".to_string()),
name: Some("Test Claude".to_string()),
homepage: Some("https://example.com".to_string()),
endpoint: Some("https://api.example.com/v1/".to_string()),
api_key: Some("sk-main".to_string()),
icon: None,
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: None,
config_format: None,
config_url: None,
apps: None,
repo: None,
directory: None,
branch: None,
content: None,
description: None,
enabled: None,
usage_enabled: Some(true),
usage_script: None,
usage_api_key: None,
usage_base_url: None,
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
};
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
let script = provider
.meta
.as_ref()
.and_then(|meta| meta.usage_script.as_ref())
.expect("usage script should be created");
assert!(script.enabled);
assert_eq!(script.api_key, None);
assert_eq!(script.base_url, None);
}
#[test]
fn test_deeplink_usage_script_omits_explicit_credentials_that_match_provider() {
use super::provider::build_provider_from_request;
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("claude".to_string()),
name: Some("Test Claude".to_string()),
homepage: Some("https://example.com".to_string()),
endpoint: Some("https://api.example.com/v1/".to_string()),
api_key: Some("sk-main".to_string()),
icon: None,
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: None,
config_format: None,
config_url: None,
apps: None,
repo: None,
directory: None,
branch: None,
content: None,
description: None,
enabled: None,
usage_enabled: Some(true),
usage_script: None,
usage_api_key: Some(" sk-main ".to_string()),
usage_base_url: Some(" https://api.example.com/v1/ ".to_string()),
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
};
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
let script = provider
.meta
.as_ref()
.and_then(|meta| meta.usage_script.as_ref())
.expect("usage script should be created");
assert_eq!(script.api_key, None);
assert_eq!(script.base_url, None);
}
#[test]
fn test_deeplink_usage_script_preserves_distinct_usage_credentials() {
use super::provider::build_provider_from_request;
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("claude".to_string()),
name: Some("Test Claude".to_string()),
homepage: Some("https://example.com".to_string()),
endpoint: Some("https://api.example.com/v1".to_string()),
api_key: Some("sk-main".to_string()),
icon: None,
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: None,
config_format: None,
config_url: None,
apps: None,
repo: None,
directory: None,
branch: None,
content: None,
description: None,
enabled: None,
usage_enabled: Some(true),
usage_script: None,
usage_api_key: Some(" sk-usage ".to_string()),
usage_base_url: Some(" https://usage.example/api/ ".to_string()),
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
};
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
let script = provider
.meta
.as_ref()
.and_then(|meta| meta.usage_script.as_ref())
.expect("usage script should be created");
assert_eq!(script.api_key.as_deref(), Some("sk-usage"));
assert_eq!(
script.base_url.as_deref(),
Some("https://usage.example/api")
);
}
#[test]
fn test_parse_and_merge_config_claude() {
// Prepare Base64 encoded Claude config
+250 -2
View File
@@ -46,16 +46,55 @@ use std::sync::{Mutex, OnceLock};
/// 获取 Hermes 配置目录
///
/// 默认路径: `~/.hermes/`
/// 可通过 settings.hermes_config_dir 覆盖
/// 解析顺序对齐 Hermes 自身的 `get_hermes_home()`:
/// 1. CCS 设置 `hermes_config_dir`(显式覆盖)
/// 2. `HERMES_HOME` 环境变量(trim 后非空;按原样,不展开 `~`,与 Hermes `Path(val)` 一致)
/// 3. 平台默认(Windows: `%LOCALAPPDATA%\hermes`,Mac/Linux: `~/.hermes`)
pub fn get_hermes_dir() -> PathBuf {
if let Some(override_dir) = get_hermes_override_dir() {
return override_dir;
}
if let Some(raw) = std::env::var_os("HERMES_HOME") {
let value = raw.to_string_lossy();
let trimmed = value.trim();
if !trimmed.is_empty() {
return PathBuf::from(trimmed);
}
}
default_hermes_dir()
}
/// 平台默认 Hermes 目录(Windows):对齐 Hermes `_get_platform_default_hermes_home()`——
/// 读 `LOCALAPPDATA` 环境变量,缺失/空时回退 `~\AppData\Local`,再拼 `hermes`。
#[cfg(target_os = "windows")]
fn default_hermes_dir() -> PathBuf {
windows_local_hermes_dir(
std::env::var_os("LOCALAPPDATA").as_deref(),
&crate::config::get_home_dir(),
)
}
/// 平台默认 Hermes 目录(Mac/Linux):`~/.hermes`。
#[cfg(not(target_os = "windows"))]
fn default_hermes_dir() -> PathBuf {
crate::config::get_home_dir().join(".hermes")
}
/// Windows `%LOCALAPPDATA%\hermes` 路径计算(纯函数,便于跨平台单测)。
/// 对齐 Hermes 的 `os.environ.get("LOCALAPPDATA", "").strip()`:trim 后为空
/// (缺失/空/纯空白)则回退 `<home>\AppData\Local\hermes`。
#[cfg(any(target_os = "windows", test))]
fn windows_local_hermes_dir(localappdata: Option<&std::ffi::OsStr>, home: &Path) -> PathBuf {
localappdata
.map(|value| value.to_string_lossy().trim().to_string())
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| home.join("AppData").join("Local"))
.join("hermes")
}
/// 获取 Hermes 配置文件路径
///
/// 返回 `~/.hermes/config.yaml`
@@ -1120,7 +1159,22 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let old_test_home = std::env::var_os("CC_SWITCH_TEST_HOME");
std::env::set_var("CC_SWITCH_TEST_HOME", tmp.path());
// Neutralize the env vars get_hermes_dir() consults, so an ambient
// HERMES_HOME / LOCALAPPDATA (e.g. set by a Hermes install) can't make
// tests escape the temp home. Restored below.
let old_hermes_home = std::env::var_os("HERMES_HOME");
let old_local_appdata = std::env::var_os("LOCALAPPDATA");
std::env::remove_var("HERMES_HOME");
std::env::remove_var("LOCALAPPDATA");
let result = test_fn();
match old_local_appdata {
Some(value) => std::env::set_var("LOCALAPPDATA", value),
None => std::env::remove_var("LOCALAPPDATA"),
}
match old_hermes_home {
Some(value) => std::env::set_var("HERMES_HOME", value),
None => std::env::remove_var("HERMES_HOME"),
}
match old_test_home {
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
@@ -2193,4 +2247,198 @@ user_profile_enabled: false
assert_eq!(user, MemoryKind::User);
assert!(serde_json::from_str::<MemoryKind>("\"bogus\"").is_err());
}
// ---- get_hermes_dir resolution (platform default + HERMES_HOME) ----
#[test]
#[serial]
fn hermes_home_env_takes_precedence_over_platform_default() {
with_test_home(|| {
// Clear any settings override so resolution reaches the HERMES_HOME branch.
let mut s = crate::settings::get_settings();
s.hermes_config_dir = None;
crate::settings::update_settings(s).unwrap();
let old = std::env::var_os("HERMES_HOME");
let custom = std::env::temp_dir().join("ccs-hermes-home-precedence");
std::env::set_var("HERMES_HOME", &custom);
let dir = get_hermes_dir();
match old {
Some(v) => std::env::set_var("HERMES_HOME", v),
None => std::env::remove_var("HERMES_HOME"),
}
assert_eq!(
dir, custom,
"HERMES_HOME should take precedence over the platform default, got {dir:?}"
);
});
}
#[test]
#[serial]
fn settings_override_takes_precedence_over_hermes_home() {
with_test_home(|| {
let custom = tempfile::tempdir().unwrap();
let custom_path = custom.path().to_path_buf();
let mut s = crate::settings::get_settings();
s.hermes_config_dir = Some(custom_path.to_string_lossy().to_string());
crate::settings::update_settings(s).unwrap();
let old = std::env::var_os("HERMES_HOME");
std::env::set_var("HERMES_HOME", "/tmp/should-be-ignored");
let dir = get_hermes_dir();
match old {
Some(v) => std::env::set_var("HERMES_HOME", v),
None => std::env::remove_var("HERMES_HOME"),
}
let mut s = crate::settings::get_settings();
s.hermes_config_dir = None;
crate::settings::update_settings(s).unwrap();
assert_eq!(
dir, custom_path,
"settings override should win over HERMES_HOME, got {dir:?}"
);
});
}
#[test]
#[serial]
fn blank_hermes_home_falls_through_to_platform_default() {
with_test_home(|| {
let mut s = crate::settings::get_settings();
s.hermes_config_dir = None;
crate::settings::update_settings(s).unwrap();
let old = std::env::var_os("HERMES_HOME");
std::env::set_var("HERMES_HOME", " ");
let dir = get_hermes_dir();
match old {
Some(v) => std::env::set_var("HERMES_HOME", v),
None => std::env::remove_var("HERMES_HOME"),
}
// Blank HERMES_HOME is ignored (matches Hermes' `.strip()` non-empty check),
// so resolution must reach the platform default, never the literal blank path.
assert_ne!(dir, PathBuf::from(" "));
assert_eq!(dir, default_hermes_dir());
});
}
#[test]
#[serial]
fn default_hermes_dir_without_override_is_platform_correct() {
with_test_home(|| {
let mut s = crate::settings::get_settings();
s.hermes_config_dir = None;
crate::settings::update_settings(s).unwrap();
let old = std::env::var_os("HERMES_HOME");
std::env::remove_var("HERMES_HOME");
let dir = get_hermes_dir();
if let Some(v) = old {
std::env::set_var("HERMES_HOME", v);
}
#[cfg(target_os = "windows")]
assert!(
dir.ends_with("hermes") && dir.to_string_lossy().to_lowercase().contains("local"),
"Windows default should be %LOCALAPPDATA%\\hermes, got {dir:?}"
);
#[cfg(not(target_os = "windows"))]
assert!(
dir.ends_with(".hermes"),
"Unix default should be ~/.hermes, got {dir:?}"
);
});
}
#[test]
fn windows_local_hermes_dir_uses_localappdata_when_set() {
let local = std::ffi::OsString::from("C:\\Users\\tester\\AppData\\Local");
let home = Path::new("C:\\Users\\tester");
// Uses LOCALAPPDATA (ignoring home) and appends `hermes`. Build the expected
// path with `join` so the separator matches the host OS.
assert_eq!(
windows_local_hermes_dir(Some(local.as_os_str()), home),
PathBuf::from(&local).join("hermes"),
);
}
#[test]
fn windows_local_hermes_dir_falls_back_to_home_when_localappdata_missing_or_empty() {
let home = Path::new("C:\\Users\\tester");
let expected = home.join("AppData").join("Local").join("hermes");
assert_eq!(windows_local_hermes_dir(None, home), expected);
let empty = std::ffi::OsString::from("");
assert_eq!(
windows_local_hermes_dir(Some(empty.as_os_str()), home),
expected,
);
}
#[test]
fn windows_local_hermes_dir_trims_localappdata() {
// Mirror Hermes' `os.environ.get("LOCALAPPDATA", "").strip()`.
let home = Path::new("C:\\Users\\tester");
// Whitespace-only -> treated as unset, fall back to <home>\AppData\Local.
let blank = std::ffi::OsString::from(" ");
assert_eq!(
windows_local_hermes_dir(Some(blank.as_os_str()), home),
home.join("AppData").join("Local").join("hermes"),
);
// Padded value -> trimmed before use.
let padded = std::ffi::OsString::from(" C:\\Custom\\Local ");
assert_eq!(
windows_local_hermes_dir(Some(padded.as_os_str()), home),
PathBuf::from("C:\\Custom\\Local").join("hermes"),
);
}
#[test]
#[serial]
fn with_test_home_neutralizes_hermes_env_vars() {
// An ambient HERMES_HOME / LOCALAPPDATA (e.g. set by a Hermes install)
// must not leak into the test home — otherwise other tests in this
// module would read/write a real Hermes config via get_hermes_config_path().
let saved_hh = std::env::var_os("HERMES_HOME");
let saved_la = std::env::var_os("LOCALAPPDATA");
std::env::set_var("HERMES_HOME", "/ambient/hermes-home");
std::env::set_var("LOCALAPPDATA", "/ambient/local-appdata");
let inside = with_test_home(|| {
(
std::env::var_os("HERMES_HOME"),
std::env::var_os("LOCALAPPDATA"),
)
});
match saved_hh {
Some(v) => std::env::set_var("HERMES_HOME", v),
None => std::env::remove_var("HERMES_HOME"),
}
match saved_la {
Some(v) => std::env::set_var("LOCALAPPDATA", v),
None => std::env::remove_var("LOCALAPPDATA"),
}
assert_eq!(
inside.0, None,
"with_test_home must clear ambient HERMES_HOME"
);
assert_eq!(
inside.1, None,
"with_test_home must clear ambient LOCALAPPDATA"
);
}
}
+14 -1
View File
@@ -5,6 +5,17 @@ use std::sync::{OnceLock, RwLock};
pub struct InitErrorPayload {
pub path: String,
pub error: String,
/// 错误类别。`Some("db_version_too_new")` 表示数据库版本过新(应用过旧),
/// 前端据此展示「升级应用」恢复界面而非直接退出。
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
/// 磁盘上数据库的 user_version(数据库版本过新时填充)
#[serde(skip_serializing_if = "Option::is_none")]
pub db_version: Option<i32>,
/// 当前应用支持的 SCHEMA_VERSION(数据库版本过新时填充)。
/// 当升级到最新版后 db_version 仍 > supported_version,说明可能由第三方客户端创建。
#[serde(skip_serializing_if = "Option::is_none")]
pub supported_version: Option<i32>,
}
static INIT_ERROR: OnceLock<RwLock<Option<InitErrorPayload>>> = OnceLock::new();
@@ -13,7 +24,6 @@ fn cell() -> &'static RwLock<Option<InitErrorPayload>> {
INIT_ERROR.get_or_init(|| RwLock::new(None))
}
#[allow(dead_code)]
pub fn set_init_error(payload: InitErrorPayload) {
#[allow(clippy::unwrap_used)]
if let Ok(mut guard) = cell().write() {
@@ -102,6 +112,9 @@ mod tests {
let payload = InitErrorPayload {
path: "/tmp/config.json".into(),
error: "broken json".into(),
kind: None,
db_version: None,
supported_version: None,
};
set_init_error(payload.clone());
let got = get_init_error().expect("should get payload back");
+40
View File
@@ -270,6 +270,16 @@ pub fn run() {
// 拦截窗口关闭:根据设置决定是否最小化到托盘
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
// 数据库版本过新的恢复模式下没有托盘可唤回,关闭即退出,避免应用隐身后台
let in_db_recovery = crate::init_status::get_init_error()
.map(|p| p.kind.as_deref() == Some("db_version_too_new"))
.unwrap_or(false);
if in_db_recovery {
api.prevent_close();
window.app_handle().exit(0);
return;
}
let settings = crate::settings::get_settings();
if settings.minimize_to_tray_on_close {
@@ -403,6 +413,35 @@ pub fn run() {
// 说明:从 v3.8.* 升级的用户通常会走到这里的 SQLite schema 迁移,
// 若迁移失败(数据库损坏/权限不足/user_version 过新等),需要给用户明确提示,
// 否则表现可能只是“应用打不开/闪退”。
//
// 预检:数据库版本过新时,必须先于任何 schema 写操作(create_tables 内含
// DROP/ALTER 等 DDL)进入恢复界面,避免旧应用对读不懂的更新版 DB 落写。
match crate::database::Database::stored_user_version_exceeds_supported(&db_path) {
Ok(Some(version)) => {
log::warn!("数据库版本过新(v{version}),引导用户在应用内升级应用");
crate::init_status::set_init_error(crate::init_status::InitErrorPayload {
path: db_path.display().to_string(),
error: format!(
"数据库版本过新({version}),当前应用仅支持 {},请升级应用后再尝试。",
crate::database::SCHEMA_VERSION
),
kind: Some("db_version_too_new".to_string()),
db_version: Some(version),
supported_version: Some(crate::database::SCHEMA_VERSION),
});
// 主窗口默认 visible:false,恢复界面必须强制显示
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
return Ok(());
}
Ok(None) => {}
Err(e) => {
log::warn!("预检数据库版本失败,继续正常初始化流程: {e}");
}
}
let db = loop {
match crate::database::Database::init() {
Ok(db) => break Arc::new(db),
@@ -1185,6 +1224,7 @@ pub fn run() {
commands::set_log_config,
commands::restart_app,
commands::install_update_and_restart,
commands::check_app_update_available,
commands::check_for_updates,
commands::is_portable_mode,
commands::copy_text_to_clipboard,
+13
View File
@@ -16,6 +16,19 @@ fn main() {
if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err() {
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
}
// AppImage 的 GTK 启动钩子 (linuxdeploy-plugin-gtk.sh) 会无条件
// `export GDK_BACKEND=x11` 强制走 XWayland,以规避历史上的 Wayland 崩溃
// (tauri-apps/tauri#8541)。但在较新的 Wayland + NVIDIA 环境下,强制 XWayland
// 反而使 WebKitGTK 的 webview 收不到指针事件(标题栏可点、网页内容点不动),
// resize 后黑屏;改回原生 Wayland 即可解决,且该崩溃在 WebKitGTK 2.52 上已不复现。
// 由于该钩子会覆盖用户预设的 GDK_BACKEND,这里提供一个钩子不会触碰的逃生开关:
// 设置 CC_SWITCH_GDK_BACKEND=wayland 即可强制覆盖,默认行为保持不变(零回归)。
if let Ok(backend) = std::env::var("CC_SWITCH_GDK_BACKEND") {
if !backend.is_empty() {
std::env::set_var("GDK_BACKEND", backend);
}
}
}
cc_switch_lib::run();
+51 -2
View File
@@ -382,6 +382,21 @@ pub struct CodexChatReasoningConfig {
pub output_format: Option<String>,
}
/// Local proxy request overrides applied after route/protocol transforms.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LocalProxyRequestOverrides {
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub headers: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<serde_json::Value>,
}
impl LocalProxyRequestOverrides {
pub fn is_empty(&self) -> bool {
self.headers.is_empty() && self.body.is_none()
}
}
/// 供应商元数据
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderMeta {
@@ -466,6 +481,12 @@ pub struct ProviderMeta {
/// Custom User-Agent for local proxy routing.
#[serde(rename = "customUserAgent", skip_serializing_if = "Option::is_none")]
pub custom_user_agent: Option<String>,
/// Local proxy request overrides applied to the transformed upstream request.
#[serde(
rename = "localProxyRequestOverrides",
skip_serializing_if = "Option::is_none"
)]
pub local_proxy_request_overrides: Option<LocalProxyRequestOverrides>,
/// 累加模式应用中,该 provider 是否已写入 live config。
/// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。
#[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")]
@@ -932,10 +953,11 @@ pub struct OpenCodeModelLimit {
#[cfg(test)]
mod tests {
use super::{
ClaudeModelConfig, CodexModelConfig, GeminiModelConfig, OpenCodeProviderConfig, Provider,
ProviderManager, ProviderMeta, UniversalProvider,
ClaudeModelConfig, CodexModelConfig, GeminiModelConfig, LocalProxyRequestOverrides,
OpenCodeProviderConfig, Provider, ProviderManager, ProviderMeta, UniversalProvider,
};
use serde_json::json;
use std::collections::HashMap;
#[test]
fn provider_meta_serializes_pricing_model_source() {
@@ -963,6 +985,33 @@ mod tests {
assert!(value.get("pricingModelSource").is_none());
}
#[test]
fn provider_meta_roundtrips_local_proxy_request_overrides() {
let meta = ProviderMeta {
local_proxy_request_overrides: Some(LocalProxyRequestOverrides {
headers: HashMap::from([("X-Test".to_string(), "yes".to_string())]),
body: Some(json!({ "temperature": 0.2 })),
}),
..ProviderMeta::default()
};
let value = serde_json::to_value(&meta).expect("serialize ProviderMeta");
assert_eq!(
value["localProxyRequestOverrides"]["headers"]["X-Test"],
"yes"
);
assert_eq!(
value["localProxyRequestOverrides"]["body"]["temperature"],
0.2
);
let decoded: ProviderMeta =
serde_json::from_value(value).expect("deserialize ProviderMeta");
let overrides = decoded.local_proxy_request_overrides.unwrap();
assert_eq!(overrides.headers.get("X-Test"), Some(&"yes".to_string()));
assert_eq!(overrides.body.unwrap()["temperature"], 0.2);
}
#[test]
fn provider_with_id_populates_defaults() {
let settings_config = json!({
+234
View File
@@ -0,0 +1,234 @@
//! HTTP content-encoding 工具。
//!
//! reqwest 的自动解压已禁用(为了透传 accept-encoding),需要手动解压。
//! 请求侧(如 Codex Desktop 在登录态发压缩请求体)与响应侧(上游压缩响应体)
//! 共用同一套解压逻辑。
use axum::http::header::HeaderMap;
use std::io::Read;
/// 把 content-encoding 值拆成有序 coding 列表(去掉 identity 与空值)。
///
/// HTTP 允许堆叠编码(如 `gzip, zstd`),各 coding 以逗号分隔;亦允许重复
/// content-encoding 头,语义等同逗号拼接(见 [`get_content_encoding`])。
fn split_codings(content_encoding: &str) -> Vec<&str> {
content_encoding
.split(',')
.map(str::trim)
.filter(|c| !c.is_empty() && *c != "identity")
.collect()
}
/// 单个 coding 是否可被解压。
fn is_single_supported(coding: &str) -> bool {
matches!(
coding,
"gzip" | "x-gzip" | "deflate" | "br" | "zstd" | "zst"
)
}
/// 解压单个 content-coding。未知编码返回 `Ok(None)`。
fn decompress_single(coding: &str, body: &[u8]) -> Result<Option<Vec<u8>>, std::io::Error> {
match coding {
"gzip" | "x-gzip" => {
let mut decoder = flate2::read::GzDecoder::new(body);
let mut decompressed = Vec::new();
decoder.read_to_end(&mut decompressed)?;
Ok(Some(decompressed))
}
"deflate" => {
// RFC 9110: deflate 指 zlib 包裹格式;但部分上游 / 客户端发 raw deflate 流。
// 先按规范尝试 zlib,失败再回退 raw —— 否则合规来源必然解压失败,
// 原始压缩字节会被 fail-open 透传给 JSON 解析(#2234 形态 C 之一)。
let mut decompressed = Vec::new();
let mut zlib = flate2::read::ZlibDecoder::new(body);
match zlib.read_to_end(&mut decompressed) {
Ok(_) => Ok(Some(decompressed)),
Err(zlib_err) => {
log::debug!("deflate 按 zlib 解压失败({zlib_err}),回退 raw deflate");
let mut decompressed = Vec::new();
let mut raw = flate2::read::DeflateDecoder::new(body);
raw.read_to_end(&mut decompressed)?;
Ok(Some(decompressed))
}
}
}
"br" => {
let mut decompressed = Vec::new();
brotli::BrotliDecompress(&mut std::io::Cursor::new(body), &mut decompressed)?;
Ok(Some(decompressed))
}
"zstd" | "zst" => {
// Codex 登录态对请求体启用 zstdCompression::Zstd);上游也可能 zstd 压缩响应。
let decompressed = zstd::stream::decode_all(std::io::Cursor::new(body))?;
Ok(Some(decompressed))
}
_ => Ok(None),
}
}
/// 根据 content-encoding 解压 body 字节,支持堆叠编码(如 `gzip, zstd`)。
///
/// RFC 9110 §8.4codings 按**应用顺序**列出,故解压须**反向**(最后应用的先解)。
/// 返回 `Ok(None)` 表示存在不受支持的编码、原样透传——此时调用方必须保留
/// content-encoding 头,否则下游(诊断 / 客户端)会把压缩字节误当明文。
pub(crate) fn decompress_body(
content_encoding: &str,
body: &[u8],
) -> Result<Option<Vec<u8>>, std::io::Error> {
let codings = split_codings(content_encoding);
if codings.is_empty() {
return Ok(None);
}
// 任一 coding 不支持就整体放弃解压、保头透传,避免半解码的脏数据。
if !codings.iter().all(|c| is_single_supported(c)) {
log::warn!("不支持的 content-encoding: {content_encoding},跳过解压");
return Ok(None);
}
// 反向解码:列表末尾是最后应用的编码,须最先解。
let mut data: Option<Vec<u8>> = None;
for coding in codings.iter().rev() {
let input = data.as_deref().unwrap_or(body);
match decompress_single(coding, input)? {
Some(decompressed) => data = Some(decompressed),
// 上面 is_single_supported 已校验,理论不会发生;防御性兜底。
None => return Ok(None),
}
}
Ok(data)
}
/// 该 content-encoding(含堆叠,如 `gzip, zstd`)是否全部可被解压。
///
/// 请求侧用它做闸门:无法解压的压缩体不能透传给 JSON 解析,需直接拒绝。
pub(crate) fn is_supported_content_encoding(content_encoding: &str) -> bool {
let codings = split_codings(content_encoding);
!codings.is_empty() && codings.iter().all(|c| is_single_supported(c))
}
/// 从 header 提取 content-encoding(合并重复头,忽略 identity 与空值)。
///
/// HTTP 允许重复 content-encoding 头,语义等同逗号拼接,故用 `get_all` 合并;
/// 返回值可能含多个逗号分隔的 coding,交由 [`decompress_body`] 反向解码。
pub(crate) fn get_content_encoding(headers: &HeaderMap) -> Option<String> {
let combined = headers
.get_all("content-encoding")
.iter()
.filter_map(|v| v.to_str().ok())
.map(str::trim)
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(", ")
.to_lowercase();
if split_codings(&combined).is_empty() {
return None;
}
Some(combined)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
#[test]
fn decompress_body_deflate_handles_zlib_wrapped_per_rfc9110() {
// RFC 9110 规范的 deflate = zlib 包裹格式(合规来源发的就是这个)
let payload = br#"{"ok":true}"#;
let mut encoder =
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
std::io::Write::write_all(&mut encoder, payload).unwrap();
let compressed = encoder.finish().unwrap();
let decompressed = decompress_body("deflate", &compressed).unwrap().unwrap();
assert_eq!(decompressed, payload);
}
#[test]
fn decompress_body_deflate_falls_back_to_raw_stream() {
// 部分来源违规发 raw deflate 流,保持兼容
let payload = br#"{"ok":true}"#;
let mut encoder =
flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
std::io::Write::write_all(&mut encoder, payload).unwrap();
let compressed = encoder.finish().unwrap();
let decompressed = decompress_body("deflate", &compressed).unwrap().unwrap();
assert_eq!(decompressed, payload);
}
#[test]
fn decompress_body_zstd_roundtrip() {
// Codex 登录态发的就是 zstd 压缩请求体
let payload = br#"{"hello":"world","n":42}"#;
let compressed = zstd::stream::encode_all(std::io::Cursor::new(&payload[..]), 0).unwrap();
let decompressed = decompress_body("zstd", &compressed).unwrap().unwrap();
assert_eq!(decompressed, payload);
}
#[test]
fn decompress_body_stacked_gzip_then_zstd_decodes_in_reverse() {
// Content-Encoding: gzip, zstd 表示先 gzip 后 zstd,解压须反向(先 zstd 后 gzip)
let payload = br#"{"stacked":true}"#;
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
std::io::Write::write_all(&mut gz, payload).unwrap();
let gzipped = gz.finish().unwrap();
let stacked = zstd::stream::encode_all(std::io::Cursor::new(&gzipped[..]), 0).unwrap();
let decompressed = decompress_body("gzip, zstd", &stacked).unwrap().unwrap();
assert_eq!(decompressed, payload);
}
#[test]
fn decompress_body_stacked_with_unsupported_returns_none() {
// 堆叠里只要有一个不支持,就整体保头透传
let result = decompress_body("snappy, zstd", b"\x00\x01\x02\x03").unwrap();
assert!(result.is_none());
}
#[test]
fn decompress_body_unknown_encoding_returns_none_to_keep_headers() {
// 未知编码必须返回 None(而非伪装成"已解码"),否则 content-encoding
// 头被剥掉,下游诊断会把压缩字节误报成明文
let result = decompress_body("snappy", b"\x00\x01\x02\x03").unwrap();
assert!(result.is_none());
}
#[test]
fn is_supported_content_encoding_matches_decompressable() {
for enc in [
"gzip",
"x-gzip",
"deflate",
"br",
"zstd",
"zst",
"gzip, zstd",
] {
assert!(is_supported_content_encoding(enc), "{enc} 应受支持");
}
for enc in ["identity", "snappy", "compress", "", "gzip, snappy"] {
assert!(!is_supported_content_encoding(enc), "{enc} 不应受支持");
}
}
#[test]
fn get_content_encoding_combines_repeated_headers() {
// 重复的 content-encoding 头等同逗号拼接,须用 get_all 合并
let mut headers = HeaderMap::new();
headers.append("content-encoding", HeaderValue::from_static("gzip"));
headers.append("content-encoding", HeaderValue::from_static("zstd"));
assert_eq!(
get_content_encoding(&headers).as_deref(),
Some("gzip, zstd")
);
}
#[test]
fn get_content_encoding_ignores_identity_only() {
let mut headers = HeaderMap::new();
headers.append("content-encoding", HeaderValue::from_static("identity"));
assert_eq!(get_content_encoding(&headers), None);
}
}
+299 -3
View File
@@ -5,6 +5,7 @@
use super::hyper_client::ProxyResponse;
use super::{
body_filter::filter_private_params_with_whitelist,
content_encoding::{decompress_body, get_content_encoding},
error::*,
failover_switch::FailoverSwitchManager,
json_canonical::{canonicalize_value, short_value_hash},
@@ -24,7 +25,10 @@ use super::{
use crate::commands::{CodexOAuthState, CopilotAuthState};
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
use crate::proxy::providers::copilot_auth::CopilotAuthManager;
use crate::{app_config::AppType, provider::Provider};
use crate::{
app_config::AppType,
provider::{LocalProxyRequestOverrides, Provider},
};
use futures::StreamExt;
use http::Extensions;
use serde_json::Value;
@@ -1386,7 +1390,18 @@ impl RequestForwarder {
// 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游
// 默认使用空白名单,过滤所有 _ 前缀字段
let filtered_body = prepare_upstream_request_body(request_body);
let mut filtered_body = prepare_upstream_request_body(request_body);
if !is_copilot {
if let Some(overrides) = provider
.meta
.as_ref()
.and_then(|meta| meta.local_proxy_request_overrides.as_ref())
{
if apply_local_proxy_body_overrides(&mut filtered_body, overrides) {
filtered_body = prepare_upstream_request_body(filtered_body);
}
}
}
// 出站 body 定稿后刷新真值(覆盖 Codex chat 上游模型覆写、转换层模型改写)
if let Some(m) = filtered_body
.get("model")
@@ -1833,6 +1848,15 @@ impl RequestForwarder {
);
}
apply_local_proxy_header_overrides(
&mut ordered_headers,
provider
.meta
.as_ref()
.and_then(|meta| meta.local_proxy_request_overrides.as_ref()),
is_copilot,
);
reject_proxy_placeholder_for_managed_account_upstream(&url, &ordered_headers)?;
// 输出请求信息日志
@@ -1942,7 +1966,20 @@ impl RequestForwarder {
Ok((response, resolved_claude_api_format, outbound_model))
} else {
let status_code = status.as_u16();
let body_text = String::from_utf8(response.bytes().await?.to_vec()).ok();
// 错误响应同样可能被上游压缩(content-encoding)。reqwest 未启用任何
// 自动解压 feature,这里拿到的是原始字节;不解压的话,压缩过的错误体会
// 在 from_utf8 处变成非 UTF-8 而被丢弃,隐藏掉上游的限流/鉴权等详情。
let encoding = get_content_encoding(response.headers());
let raw = response.bytes().await?;
let decoded = match encoding {
Some(encoding) => match decompress_body(&encoding, &raw) {
Ok(Some(decompressed)) => decompressed,
// 不支持的编码 / 解压失败:退回原始字节,尽量保留可读信息
_ => raw.to_vec(),
},
None => raw.to_vec(),
};
let body_text = String::from_utf8(decoded).ok();
Err(ProxyError::UpstreamError {
status: status_code,
@@ -2544,6 +2581,154 @@ fn summarize_text_for_log(text: &str, max_chars: usize) -> String {
format!("{truncated}...")
}
fn apply_local_proxy_body_overrides(
body: &mut Value,
overrides: &LocalProxyRequestOverrides,
) -> bool {
let Some(override_body) = overrides.body.as_ref() else {
return false;
};
if !override_body.is_object() {
log::warn!("[LocalProxyOverrides] Ignoring body override because it is not an object");
return false;
}
merge_json_override(body, override_body)
}
fn merge_json_override(target: &mut Value, patch: &Value) -> bool {
merge_json_override_inner(target, patch, true)
}
fn merge_json_override_inner(target: &mut Value, patch: &Value, is_top_level: bool) -> bool {
match (target, patch) {
(Value::Object(target_map), Value::Object(patch_map)) => {
let mut changed = false;
for (key, patch_value) in patch_map {
if is_top_level && key == "stream" {
log::warn!(
"[LocalProxyOverrides] Ignoring body override for protected field: stream"
);
continue;
}
match target_map.get_mut(key) {
Some(target_value) => {
changed |= merge_json_override_inner(target_value, patch_value, false);
}
None => {
target_map.insert(key.clone(), patch_value.clone());
changed = true;
}
}
}
changed
}
(target_value, patch_value) => {
if target_value == patch_value {
false
} else {
*target_value = patch_value.clone();
true
}
}
}
}
fn apply_local_proxy_header_overrides(
headers: &mut http::HeaderMap,
overrides: Option<&LocalProxyRequestOverrides>,
is_copilot: bool,
) {
if is_copilot {
return;
}
let Some(header_overrides) = overrides.map(|overrides| &overrides.headers) else {
return;
};
for (raw_name, raw_value) in header_overrides {
let header_name = raw_name.trim().to_ascii_lowercase();
if header_name.is_empty() {
log::warn!("[LocalProxyOverrides] Ignoring header override with empty name");
continue;
}
let Ok(name) = http::HeaderName::from_bytes(header_name.as_bytes()) else {
log::warn!("[LocalProxyOverrides] Ignoring invalid header override name: {raw_name}");
continue;
};
if is_protected_local_proxy_override_header(&name) {
log::debug!(
"[LocalProxyOverrides] Ignoring protected header override: {}",
name.as_str()
);
continue;
}
let Ok(value) = http::HeaderValue::from_str(raw_value) else {
log::warn!(
"[LocalProxyOverrides] Ignoring invalid header override value for {}",
name.as_str()
);
continue;
};
headers.insert(name, value);
}
}
fn is_protected_local_proxy_override_header(name: &http::HeaderName) -> bool {
matches!(
name.as_str(),
"host"
| "content-length"
| "transfer-encoding"
| "connection"
| "proxy-authorization"
| "proxy-authenticate"
| "te"
| "trailer"
| "upgrade"
| "accept-encoding"
| "content-type"
| "authorization"
| "x-api-key"
| "x-goog-api-key"
| "chatgpt-account-id"
| "session_id"
| "x-client-request-id"
| "x-codex-window-id"
| "x-forwarded-host"
| "x-forwarded-port"
| "x-forwarded-proto"
| "forwarded"
| "cf-connecting-ip"
| "cf-ipcountry"
| "cf-ray"
| "cf-visitor"
| "true-client-ip"
| "fastly-client-ip"
| "x-azure-clientip"
| "x-azure-fdid"
| "x-azure-ref"
| "akamai-origin-hop"
| "x-akamai-config-log-detail"
| "x-request-id"
| "x-correlation-id"
| "x-trace-id"
| "x-amzn-trace-id"
| "x-b3-traceid"
| "x-b3-spanid"
| "x-b3-parentspanid"
| "x-b3-sampled"
| "traceparent"
| "tracestate"
)
}
fn prepare_upstream_request_body(request_body: Value) -> Value {
canonicalize_value(filter_private_params_with_whitelist(request_body, &[]))
}
@@ -2607,6 +2792,7 @@ fn value_for_log(value: &Value) -> String {
mod tests {
use super::*;
use crate::database::Database;
use crate::provider::LocalProxyRequestOverrides;
use axum::http::header::{HeaderValue, ACCEPT};
use axum::http::HeaderMap;
use bytes::Bytes;
@@ -2806,6 +2992,116 @@ mod tests {
);
}
#[test]
fn local_proxy_body_overrides_deep_merge_final_body_without_stream() {
let mut body = json!({
"model": "before",
"stream": false,
"metadata": {
"keep": true,
"temperature": 1
},
"messages": [{ "role": "user", "content": "hello" }]
});
let overrides = LocalProxyRequestOverrides {
headers: HashMap::new(),
body: Some(json!({
"model": "after",
"stream": true,
"metadata": {
"temperature": 0.2,
"top_p": 0.9
},
"messages": []
})),
};
assert!(apply_local_proxy_body_overrides(&mut body, &overrides));
assert_eq!(body["model"], "after");
assert_eq!(body["stream"], false);
assert_eq!(body["metadata"]["keep"], true);
assert_eq!(body["metadata"]["temperature"], 0.2);
assert_eq!(body["metadata"]["top_p"], 0.9);
assert_eq!(body["messages"], json!([]));
}
#[test]
fn local_proxy_header_overrides_replace_allowed_headers_only() {
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::USER_AGENT,
http::HeaderValue::from_static("original"),
);
headers.insert(
http::header::AUTHORIZATION,
http::HeaderValue::from_static("Bearer good"),
);
headers.insert(
http::header::CONTENT_TYPE,
http::HeaderValue::from_static("application/json"),
);
let overrides = LocalProxyRequestOverrides {
headers: HashMap::from([
("User-Agent".to_string(), "custom".to_string()),
("X-Test".to_string(), "ok".to_string()),
("Authorization".to_string(), "Bearer bad".to_string()),
("Content-Type".to_string(), "text/plain".to_string()),
("X-Bad".to_string(), "bad\nvalue".to_string()),
]),
body: None,
};
apply_local_proxy_header_overrides(&mut headers, Some(&overrides), false);
assert_eq!(
headers
.get(http::header::USER_AGENT)
.and_then(|value| value.to_str().ok()),
Some("custom")
);
assert_eq!(
headers
.get(http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok()),
Some("Bearer good")
);
assert_eq!(
headers
.get(http::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok()),
Some("application/json")
);
assert_eq!(
headers.get("x-test").and_then(|value| value.to_str().ok()),
Some("ok")
);
assert!(headers.get("x-bad").is_none());
}
#[test]
fn local_proxy_header_overrides_are_skipped_for_copilot() {
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::USER_AGENT,
http::HeaderValue::from_static("copilot"),
);
let overrides = LocalProxyRequestOverrides {
headers: HashMap::from([("User-Agent".to_string(), "custom".to_string())]),
body: None,
};
apply_local_proxy_header_overrides(&mut headers, Some(&overrides), true);
assert_eq!(
headers
.get(http::header::USER_AGENT)
.and_then(|value| value.to_str().ok()),
Some("copilot")
);
}
#[tokio::test]
async fn non_streaming_success_is_buffered_before_marking_provider_successful() {
let forwarder = test_forwarder(Duration::from_secs(1), Duration::from_secs(1));
+50 -3
View File
@@ -8,6 +8,7 @@
//! - Claude 的格式转换逻辑保留在此文件(用于 OpenRouter 旧接口回退)
use super::{
content_encoding::{decompress_body, get_content_encoding, is_supported_content_encoding},
error_mapper::{get_error_message, map_proxy_error_to_status},
forwarder::ActiveConnectionGuard,
handler_config::{
@@ -568,6 +569,49 @@ fn endpoint_with_query(uri: &axum::http::Uri, endpoint: &str) -> String {
}
}
/// Codex 客户端(尤其 Desktop 登录态)可能对请求体启用 zstd 压缩,使得后续
/// `serde_json::from_slice` 直接解析失败。这里在解析前解压,并剥掉已失真的实体头
/// content-encoding / content-length / transfer-encoding)——转发层会基于解压后的
/// 明文 JSON 重新生成正确的头。
fn decode_codex_request_body(
headers: &mut axum::http::HeaderMap,
body_bytes: Bytes,
) -> Result<Bytes, ProxyError> {
let Some(encoding) = get_content_encoding(headers) else {
return Ok(body_bytes);
};
if !is_supported_content_encoding(&encoding) {
return Err(ProxyError::InvalidRequest(format!(
"Unsupported request content-encoding: {encoding}"
)));
}
log::debug!("[Codex] 解压请求体: content-encoding={encoding}");
let decompressed = match decompress_body(&encoding, &body_bytes) {
Ok(Some(decompressed)) => decompressed,
// is_supported_content_encoding 已确保编码受支持,正常不会返回 None;
// 防御性兜底:宁可报错,也不能把压缩字节当 JSON 透传下去。
Ok(None) => {
return Err(ProxyError::InvalidRequest(format!(
"Unsupported request content-encoding: {encoding}"
)));
}
Err(e) => {
log::warn!("[Codex] 请求体解压失败 ({encoding}): {e}");
return Err(ProxyError::InvalidRequest(format!(
"Failed to decompress request body ({encoding}): {e}"
)));
}
};
headers.remove(axum::http::header::CONTENT_ENCODING);
headers.remove(axum::http::header::CONTENT_LENGTH);
headers.remove(axum::http::header::TRANSFER_ENCODING);
Ok(Bytes::from(decompressed))
}
// ============================================================================
// Codex API 处理器
// ============================================================================
@@ -580,13 +624,14 @@ pub async fn handle_chat_completions(
let (parts, req_body) = request.into_parts();
let method = parts.method.clone();
let uri = parts.uri;
let headers = parts.headers;
let mut headers = parts.headers;
let extensions = parts.extensions;
let body_bytes = req_body
.collect()
.await
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
.to_bytes();
let body_bytes = decode_codex_request_body(&mut headers, body_bytes)?;
let body: Value = serde_json::from_slice(&body_bytes)
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
@@ -645,13 +690,14 @@ pub async fn handle_responses(
let (parts, req_body) = request.into_parts();
let method = parts.method.clone();
let uri = parts.uri;
let headers = parts.headers;
let mut headers = parts.headers;
let extensions = parts.extensions;
let body_bytes = req_body
.collect()
.await
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
.to_bytes();
let body_bytes = decode_codex_request_body(&mut headers, body_bytes)?;
let body: Value = serde_json::from_slice(&body_bytes)
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
@@ -723,13 +769,14 @@ pub async fn handle_responses_compact(
let (parts, req_body) = request.into_parts();
let method = parts.method.clone();
let uri = parts.uri;
let headers = parts.headers;
let mut headers = parts.headers;
let extensions = parts.extensions;
let body_bytes = req_body
.collect()
.await
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
.to_bytes();
let body_bytes = decode_codex_request_body(&mut headers, body_bytes)?;
let body: Value = serde_json::from_slice(&body_bytes)
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
+2 -1
View File
@@ -223,7 +223,8 @@ fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
// 响应解压由 response_processor 根据 content-encoding 手动处理。
.no_gzip()
.no_brotli()
.no_deflate();
.no_deflate()
.no_zstd();
// 有代理地址则使用代理,否则跟随系统代理
if let Some(url) = proxy_url {
+1
View File
@@ -5,6 +5,7 @@
pub mod body_filter;
pub mod cache_injector;
pub mod circuit_breaker;
pub(crate) mod content_encoding;
pub mod copilot_optimizer;
pub mod error;
pub mod error_mapper;
@@ -16,7 +16,7 @@
//! - 通过 JWT id_token 提取 chatgpt_account_id 作为账号唯一标识
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
@@ -236,7 +236,6 @@ pub struct CodexOAuthManager {
/// 进行中的 Device Code 流程:device_auth_id -> {user_code, expires_at_ms}
/// 过期条目会在 start_device_flow 时被清理,防止放弃的登录流程导致无界增长
pending_device_codes: Arc<RwLock<HashMap<String, PendingDeviceCode>>>,
http_client: Client,
storage_path: PathBuf,
}
@@ -250,7 +249,6 @@ impl CodexOAuthManager {
access_tokens: Arc::new(RwLock::new(HashMap::new())),
refresh_locks: Arc::new(RwLock::new(HashMap::new())),
pending_device_codes: Arc::new(RwLock::new(HashMap::new())),
http_client: Client::new(),
storage_path,
};
@@ -272,8 +270,7 @@ impl CodexOAuthManager {
pub async fn start_device_flow(&self) -> Result<GitHubDeviceCodeResponse, CodexOAuthError> {
log::info!("[CodexOAuth] 启动 Device Code 流程");
let response = self
.http_client
let response = crate::proxy::http_client::get()
.post(DEVICE_AUTH_USERCODE_URL)
.header("Content-Type", "application/json")
.header("User-Agent", CODEX_USER_AGENT)
@@ -355,8 +352,7 @@ impl CodexOAuthManager {
log::debug!("[CodexOAuth] 轮询 Device Code");
let poll_response = self
.http_client
let poll_response = crate::proxy::http_client::get()
.post(DEVICE_AUTH_TOKEN_URL)
.header("Content-Type", "application/json")
.header("User-Agent", CODEX_USER_AGENT)
@@ -443,8 +439,7 @@ impl CodexOAuthManager {
code: &str,
code_verifier: &str,
) -> Result<OAuthTokenResponse, CodexOAuthError> {
let response = self
.http_client
let response = crate::proxy::http_client::get()
.post(OAUTH_TOKEN_URL)
.header("Content-Type", "application/x-www-form-urlencoded")
.header("User-Agent", CODEX_USER_AGENT)
@@ -477,8 +472,7 @@ impl CodexOAuthManager {
&self,
refresh_token: &str,
) -> Result<OAuthTokenResponse, CodexOAuthError> {
let response = self
.http_client
let response = crate::proxy::http_client::get()
.post(OAUTH_TOKEN_URL)
.header("Content-Type", "application/x-www-form-urlencoded")
.header("User-Agent", CODEX_USER_AGENT)
+7 -18
View File
@@ -15,7 +15,6 @@
//! - Provider 通过 meta.authBinding 关联账号
//! - 自动迁移 v1 单账号格式到 v3 多账号 + 默认账号格式
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
@@ -429,8 +428,6 @@ pub struct CopilotAuthManager {
api_endpoints: Arc<RwLock<HashMap<String, String>>>,
/// 每个账号的端点拉取锁,避免并发拉取重复打 GitHub API
endpoint_locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
/// HTTP 客户端
http_client: Client,
/// 存储路径
storage_path: PathBuf,
/// 待迁移的旧格式 token
@@ -452,7 +449,6 @@ impl CopilotAuthManager {
copilot_models: Arc::new(RwLock::new(HashMap::new())),
api_endpoints: Arc::new(RwLock::new(HashMap::new())),
endpoint_locks: Arc::new(RwLock::new(HashMap::new())),
http_client: Client::new(),
storage_path,
pending_migration: Arc::new(RwLock::new(None)),
migration_error: Arc::new(RwLock::new(None)),
@@ -608,8 +604,7 @@ impl CopilotAuthManager {
};
log::info!("[CopilotAuth] 启动设备码流程 (domain: {domain})");
let response = self
.http_client
let response = crate::proxy::http_client::get()
.post(github_device_code_url(&domain))
.header("Accept", "application/json")
.header("User-Agent", COPILOT_USER_AGENT)
@@ -653,8 +648,7 @@ impl CopilotAuthManager {
};
log::debug!("[CopilotAuth] 轮询 OAuth Token (domain: {domain})");
let response = self
.http_client
let response = crate::proxy::http_client::get()
.post(github_oauth_token_url(&domain))
.header("Accept", "application/json")
.header("User-Agent", COPILOT_USER_AGENT)
@@ -837,8 +831,7 @@ impl CopilotAuthManager {
log::info!("[CopilotAuth] 获取账号 {account_id} 的 Copilot 可用模型");
let response = self
.http_client
let response = crate::proxy::http_client::get()
.get(&models_url)
.header("Authorization", format!("Bearer {copilot_token}"))
.header("Content-Type", "application/json")
@@ -925,8 +918,7 @@ impl CopilotAuthManager {
log::info!("[CopilotAuth] 获取账号 {account_id} 的 Copilot 使用量");
let response = self
.http_client
let response = crate::proxy::http_client::get()
.get(copilot_usage_url(&domain))
.header("Authorization", format!("token {github_token}"))
.header("Content-Type", "application/json")
@@ -1040,8 +1032,7 @@ impl CopilotAuthManager {
log::debug!("[CopilotAuth] 为账号 {account_id} 惰性拉取动态 API 端点");
let response = self
.http_client
let response = crate::proxy::http_client::get()
.get(copilot_usage_url(&domain))
.header("Authorization", format!("token {github_token}"))
.header("Content-Type", "application/json")
@@ -1318,8 +1309,7 @@ impl CopilotAuthManager {
github_token: &str,
domain: &str,
) -> Result<GitHubUser, CopilotAuthError> {
let response = self
.http_client
let response = crate::proxy::http_client::get()
.get(github_user_url(domain))
.header("Authorization", format!("token {github_token}"))
.header("User-Agent", COPILOT_USER_AGENT)
@@ -1351,8 +1341,7 @@ impl CopilotAuthManager {
) -> Result<(), CopilotAuthError> {
log::debug!("[CopilotAuth] 获取账号 {account_id} 的 Copilot Token (domain: {domain})");
let response = self
.http_client
let response = crate::proxy::http_client::get()
.get(copilot_token_url(domain))
.header("Authorization", format!("token {github_token}"))
.header("User-Agent", COPILOT_USER_AGENT)
+2 -87
View File
@@ -3,6 +3,7 @@
//! 统一处理流式和非流式 API 响应
use super::{
content_encoding::{decompress_body, get_content_encoding},
forwarder::ActiveConnectionGuard,
handler_config::{StreamUsageEventFilter, UsageParserConfig},
handler_context::{RequestContext, StreamingTimeoutConfig},
@@ -19,7 +20,6 @@ use bytes::Bytes;
use futures::stream::{Stream, StreamExt};
use serde_json::Value;
use std::{
io::Read,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
@@ -29,60 +29,9 @@ use std::{
use tokio::sync::Mutex;
// ============================================================================
// 响应解压
// 响应头处理
// ============================================================================
/// 根据 content-encoding 解压响应体字节
///
/// reqwest 自动解压已禁用(为了透传 accept-encoding),需要手动解压。
/// 返回 `Ok(None)` 表示编码不受支持、原样透传——此时调用方必须保留
/// content-encoding 头,否则下游(诊断/客户端)会把压缩字节误当明文。
fn decompress_body(content_encoding: &str, body: &[u8]) -> Result<Option<Vec<u8>>, std::io::Error> {
match content_encoding {
"gzip" | "x-gzip" => {
let mut decoder = flate2::read::GzDecoder::new(body);
let mut decompressed = Vec::new();
decoder.read_to_end(&mut decompressed)?;
Ok(Some(decompressed))
}
"deflate" => {
// RFC 9110: deflate 指 zlib 包裹格式;但部分上游发 raw deflate 流。
// 先按规范尝试 zlib,失败再回退 raw —— 否则合规上游必然解压失败,
// 原始压缩字节会被 fail-open 透传给 JSON 解析(#2234 形态 C 之一)。
let mut decompressed = Vec::new();
let mut zlib = flate2::read::ZlibDecoder::new(body);
match zlib.read_to_end(&mut decompressed) {
Ok(_) => Ok(Some(decompressed)),
Err(zlib_err) => {
log::debug!("deflate 按 zlib 解压失败({zlib_err}),回退 raw deflate");
let mut decompressed = Vec::new();
let mut raw = flate2::read::DeflateDecoder::new(body);
raw.read_to_end(&mut decompressed)?;
Ok(Some(decompressed))
}
}
}
"br" => {
let mut decompressed = Vec::new();
brotli::BrotliDecompress(&mut std::io::Cursor::new(body), &mut decompressed)?;
Ok(Some(decompressed))
}
_ => {
log::warn!("未知的 content-encoding: {content_encoding},跳过解压");
Ok(None)
}
}
}
/// 从响应头提取 content-encoding(忽略 identity 和 chunked
fn get_content_encoding(headers: &HeaderMap) -> Option<String> {
headers
.get("content-encoding")
.and_then(|v| v.to_str().ok())
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty() && s != "identity")
}
/// RFC 2616 / RFC 7230 中定义的不应被代理继续转发的响应头。
const HOP_BY_HOP_RESPONSE_HEADERS: &[&str] = &[
"connection",
@@ -878,40 +827,6 @@ mod tests {
use std::sync::Arc;
use tokio::sync::RwLock;
#[test]
fn decompress_body_deflate_handles_zlib_wrapped_per_rfc9110() {
// RFC 9110 规范的 deflate = zlib 包裹格式(合规上游发的就是这个)
let payload = br#"{"ok":true}"#;
let mut encoder =
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
std::io::Write::write_all(&mut encoder, payload).unwrap();
let compressed = encoder.finish().unwrap();
let decompressed = decompress_body("deflate", &compressed).unwrap().unwrap();
assert_eq!(decompressed, payload);
}
#[test]
fn decompress_body_deflate_falls_back_to_raw_stream() {
// 部分上游违规发 raw deflate 流,保持兼容
let payload = br#"{"ok":true}"#;
let mut encoder =
flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
std::io::Write::write_all(&mut encoder, payload).unwrap();
let compressed = encoder.finish().unwrap();
let decompressed = decompress_body("deflate", &compressed).unwrap().unwrap();
assert_eq!(decompressed, payload);
}
#[test]
fn decompress_body_unknown_encoding_returns_none_to_keep_headers() {
// 未知编码必须返回 None(而非伪装成"已解码"),否则 content-encoding
// 头被剥掉,下游诊断会把压缩字节误报成明文
let result = decompress_body("zstd", b"\x28\xb5\x2f\xfd").unwrap();
assert!(result.is_none());
}
#[test]
fn test_strip_sse_field_accepts_optional_space() {
assert_eq!(
@@ -0,0 +1,38 @@
{
"slug": "native-responses-template",
"display_name": "native-responses-template",
"description": "native-responses-template",
"base_instructions": "You are Codex, a coding agent. You and the user share the same workspace and collaborate to achieve the user's goals.",
"default_reasoning_level": "high",
"supported_reasoning_levels": [
{
"effort": "none",
"description": "Disable Thinking"
},
{
"effort": "high",
"description": "Enabled Thinking"
}
],
"shell_type": "shell_command",
"visibility": "list",
"supported_in_api": true,
"priority": 0,
"supports_reasoning_summaries": true,
"default_reasoning_summary": "none",
"support_verbosity": false,
"truncation_policy": {
"mode": "bytes",
"limit": 10000
},
"supports_parallel_tool_calls": false,
"supports_image_detail_original": false,
"context_window": 262144,
"max_context_window": 262144,
"effective_context_window_percent": 95,
"experimental_supported_tools": [],
"input_modalities": [
"text"
],
"supports_search_tool": false
}
+5
View File
@@ -159,11 +159,16 @@ impl ConfigService {
}
let cfg_text = settings.get("config").and_then(Value::as_str);
let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format(
provider.meta.as_ref().and_then(|m| m.api_format.as_deref()),
);
crate::codex_config::write_codex_provider_live_with_catalog(
&provider.settings_config,
provider.category.as_deref(),
auth,
cfg_text,
profile,
)?;
// 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用
// sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空
+8
View File
@@ -918,11 +918,19 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
.ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
let config_str = obj.get("config").and_then(|v| v.as_str());
// Native (direct) Responses providers must suppress Codex's freeform
// apply_patch custom tool via the generated catalog; chat/proxy
// providers keep the default tool set. Keyed on provider.meta.apiFormat.
let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format(
provider.meta.as_ref().and_then(|m| m.api_format.as_deref()),
);
crate::codex_config::write_codex_provider_live_with_catalog(
&provider.settings_config,
provider.category.as_deref(),
auth,
config_str,
profile,
)?;
if provider
.meta
+635 -11
View File
@@ -103,7 +103,7 @@ mod tests {
use crate::claude_desktop_config::PROFILE_ID;
use crate::config::{get_claude_settings_path, read_json_file, write_json_file};
use crate::database::Database;
use crate::provider::{AuthBinding, AuthBindingSource, ProviderMeta};
use crate::provider::{AuthBinding, AuthBindingSource, ProviderMeta, UsageScript};
#[cfg(any(target_os = "macos", windows))]
use crate::provider::{ClaudeDesktopMode, ClaudeDesktopModelRoute};
use crate::proxy::types::ProxyConfig;
@@ -228,6 +228,68 @@ mod tests {
result
}
fn codex_settings(base_url: &str, api_key: &str) -> Value {
json!({
"auth": {
"OPENAI_API_KEY": api_key
},
"config": format!(
"model_provider = \"custom\"\n\
[model_providers.custom]\n\
name = \"custom\"\n\
base_url = \"{base_url}\"\n\
wire_api = \"chat\"\n"
)
})
}
fn usage_script_with_credentials(
api_key: Option<&str>,
base_url: Option<&str>,
template_type: Option<&str>,
) -> UsageScript {
UsageScript {
enabled: true,
language: "javascript".to_string(),
code: "return { remaining: 1, unit: 'USD' };".to_string(),
timeout: Some(10),
api_key: api_key.map(str::to_string),
base_url: base_url.map(str::to_string),
access_token: None,
user_id: None,
template_type: template_type.map(str::to_string),
auto_query_interval: None,
coding_plan_provider: None,
access_key_id: Some("ak-test".to_string()),
secret_access_key: Some("sk-test".to_string()),
}
}
fn codex_provider_with_usage(
id: &str,
base_url: &str,
api_key: &str,
usage_api_key: Option<&str>,
usage_base_url: Option<&str>,
template_type: Option<&str>,
) -> Provider {
let mut provider = Provider::with_id(
id.to_string(),
format!("Provider {id}"),
codex_settings(base_url, api_key),
None,
);
provider.meta = Some(ProviderMeta {
usage_script: Some(usage_script_with_credentials(
usage_api_key,
usage_base_url,
template_type,
)),
..Default::default()
});
provider
}
fn openclaw_provider(id: &str) -> Provider {
Provider {
id: id.to_string(),
@@ -328,6 +390,255 @@ mod tests {
})
}
#[test]
#[serial]
fn add_clears_usage_credentials_that_match_provider_config() {
with_test_home(|state, _| {
let provider = codex_provider_with_usage(
"codex-a",
"https://api.a.example/v1/",
"sk-a",
Some(" sk-a "),
Some(" https://api.a.example/v1/ "),
None,
);
ProviderService::add(state, AppType::Codex, provider, false).expect("add provider");
let saved = state
.db
.get_provider_by_id("codex-a", AppType::Codex.as_str())
.expect("query saved provider")
.expect("saved provider should exist");
let script = saved
.meta
.as_ref()
.and_then(|meta| meta.usage_script.as_ref())
.expect("usage script should remain");
assert_eq!(script.api_key, None);
assert_eq!(script.base_url, None);
});
}
#[test]
#[serial]
fn update_preserves_usage_credentials_that_only_match_previous_config() {
with_test_home(|state, _| {
let provider = codex_provider_with_usage(
"codex-usage-old",
"https://api.a.example/v1/",
"sk-a",
Some("sk-a"),
Some("https://api.a.example/v1/"),
None,
);
state
.db
.save_provider(AppType::Codex.as_str(), &provider)
.expect("seed provider with explicit usage credentials");
let mut updated = provider.clone();
updated.settings_config = codex_settings("https://api.b.example/v1/", "sk-b");
ProviderService::update(state, AppType::Codex, None, updated)
.expect("update provider main credentials");
let saved = state
.db
.get_provider_by_id("codex-usage-old", AppType::Codex.as_str())
.expect("query updated provider")
.expect("updated provider should exist");
let script = saved
.meta
.as_ref()
.and_then(|meta| meta.usage_script.as_ref())
.expect("usage script should remain");
assert_eq!(script.api_key.as_deref(), Some("sk-a"));
assert_eq!(
script.base_url.as_deref(),
Some("https://api.a.example/v1/")
);
assert_eq!(
saved.resolve_usage_credentials(&AppType::Codex),
("https://api.b.example/v1".to_string(), "sk-b".to_string())
);
});
}
#[test]
#[serial]
fn copied_provider_uses_edited_credentials_after_add_clears_mirrored_usage_credentials() {
with_test_home(|state, _| {
let copied_provider = codex_provider_with_usage(
"codex-copy",
"https://api.a.example/v1/",
"sk-a",
Some("sk-a"),
Some("https://api.a.example/v1/"),
None,
);
ProviderService::add(state, AppType::Codex, copied_provider, false)
.expect("add copied provider");
let saved_after_add = state
.db
.get_provider_by_id("codex-copy", AppType::Codex.as_str())
.expect("query copied provider")
.expect("copied provider should exist");
let script_after_add = saved_after_add
.meta
.as_ref()
.and_then(|meta| meta.usage_script.as_ref())
.expect("usage script should remain");
assert_eq!(script_after_add.api_key, None);
assert_eq!(script_after_add.base_url, None);
let mut edited_provider = saved_after_add.clone();
edited_provider.settings_config = codex_settings("https://api.b.example/v1/", "sk-b");
ProviderService::update(state, AppType::Codex, None, edited_provider)
.expect("edit copied provider credentials");
let saved_after_update = state
.db
.get_provider_by_id("codex-copy", AppType::Codex.as_str())
.expect("query edited provider")
.expect("edited provider should exist");
let script_after_update = saved_after_update
.meta
.as_ref()
.and_then(|meta| meta.usage_script.as_ref())
.expect("usage script should remain");
assert_eq!(script_after_update.api_key, None);
assert_eq!(script_after_update.base_url, None);
assert_eq!(
saved_after_update.resolve_usage_credentials(&AppType::Codex),
("https://api.b.example/v1".to_string(), "sk-b".to_string())
);
});
}
#[test]
#[serial]
fn update_clears_usage_credentials_that_match_current_config() {
with_test_home(|state, _| {
let provider = codex_provider_with_usage(
"codex-current",
"https://api.a.example/v1",
"sk-a",
Some("sk-usage"),
Some("https://usage.example/api"),
None,
);
state
.db
.save_provider(AppType::Codex.as_str(), &provider)
.expect("seed provider with distinct usage credentials");
let mut updated = provider.clone();
updated.settings_config = codex_settings("https://api.b.example/v1/", "sk-b");
updated.meta = Some(ProviderMeta {
usage_script: Some(usage_script_with_credentials(
Some(" sk-b "),
Some(" https://api.b.example/v1/ "),
None,
)),
..Default::default()
});
ProviderService::update(state, AppType::Codex, None, updated)
.expect("update provider with redundant usage credentials");
let saved = state
.db
.get_provider_by_id("codex-current", AppType::Codex.as_str())
.expect("query updated provider")
.expect("updated provider should exist");
let script = saved
.meta
.as_ref()
.and_then(|meta| meta.usage_script.as_ref())
.expect("usage script should remain");
assert_eq!(script.api_key, None);
assert_eq!(script.base_url, None);
});
}
#[test]
#[serial]
fn add_preserves_distinct_usage_credentials() {
with_test_home(|state, _| {
let provider = codex_provider_with_usage(
"codex-distinct",
"https://api.main.example/v1",
"sk-main",
Some("sk-usage"),
Some("https://usage.example/api"),
None,
);
ProviderService::add(state, AppType::Codex, provider, false).expect("add provider");
let saved = state
.db
.get_provider_by_id("codex-distinct", AppType::Codex.as_str())
.expect("query saved provider")
.expect("saved provider should exist");
let script = saved
.meta
.as_ref()
.and_then(|meta| meta.usage_script.as_ref())
.expect("usage script should remain");
assert_eq!(script.api_key.as_deref(), Some("sk-usage"));
assert_eq!(
script.base_url.as_deref(),
Some("https://usage.example/api")
);
});
}
#[test]
#[serial]
fn add_does_not_clear_token_plan_credentials() {
with_test_home(|state, _| {
let provider = codex_provider_with_usage(
"codex-token-plan",
"https://api.plan.example/v1",
"sk-plan",
Some("sk-plan"),
Some("https://api.plan.example/v1"),
Some("token_plan"),
);
ProviderService::add(state, AppType::Codex, provider, false).expect("add provider");
let saved = state
.db
.get_provider_by_id("codex-token-plan", AppType::Codex.as_str())
.expect("query saved provider")
.expect("saved provider should exist");
let script = saved
.meta
.as_ref()
.and_then(|meta| meta.usage_script.as_ref())
.expect("usage script should remain");
assert_eq!(script.api_key.as_deref(), Some("sk-plan"));
assert_eq!(
script.base_url.as_deref(),
Some("https://api.plan.example/v1")
);
assert_eq!(script.access_key_id.as_deref(), Some("ak-test"));
assert_eq!(script.secret_access_key.as_deref(), Some("sk-test"));
});
}
#[test]
fn validate_provider_settings_rejects_missing_auth() {
let provider = Provider::with_id(
@@ -344,6 +655,83 @@ mod tests {
);
}
#[test]
fn extract_claude_common_config_strips_all_credentials_keeps_shareable() {
// env 混入多种凭据(Anthropic/OpenRouter/Google/OpenAI/Gemini + AWS/Vertex
// 与可共享配置;顶层混入非标准的 apiKey/api_key 凭据与正常设置。
let settings = json!({
"env": {
"ANTHROPIC_API_KEY": "sk-ant",
"ANTHROPIC_AUTH_TOKEN": "tok-ant",
"OPENROUTER_API_KEY": "sk-or",
"GOOGLE_API_KEY": "g-key",
"OPENAI_API_KEY": "sk-oai",
"GEMINI_API_KEY": "g-gem",
"AWS_ACCESS_KEY_ID": "AKIA",
"AWS_SECRET_ACCESS_KEY": "secret",
"AWS_SESSION_TOKEN": "sess",
"GOOGLE_APPLICATION_CREDENTIALS": "/path/creds.json",
"AWS_BEARER_TOKEN_BEDROCK": "bedrock-tok",
"ANTHROPIC_BASE_URL": "https://example.com",
"ANTHROPIC_MODEL": "claude-x",
// 可共享、非机密配置(复数 _TOKENS 不应被误剥)
"ENABLE_TOOL_SEARCH": "true",
"CLAUDE_CODE_MAX_OUTPUT_TOKENS": "8192"
},
"apiKey": "sk-top",
"api_key": "sk-top2",
"theme": "dark",
"includeCoAuthoredBy": false
});
let snippet = ProviderService::extract_claude_common_config(&settings)
.expect("extract should succeed");
let value: Value = serde_json::from_str(&snippet).expect("snippet is valid JSON");
// 所有凭据都不得出现在共享片段里
let env = value.get("env");
for leaked in [
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"OPENROUTER_API_KEY",
"GOOGLE_API_KEY",
"OPENAI_API_KEY",
"GEMINI_API_KEY",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"GOOGLE_APPLICATION_CREDENTIALS",
"AWS_BEARER_TOKEN_BEDROCK",
] {
assert!(
env.and_then(|e| e.get(leaked)).is_none(),
"credential {leaked} must not leak into common config"
);
}
assert!(
value.get("apiKey").is_none() && value.get("api_key").is_none(),
"top-level credentials must be stripped"
);
// 端点/模型(provider-specific 非机密)也应剥掉
assert!(env.and_then(|e| e.get("ANTHROPIC_BASE_URL")).is_none());
assert!(env.and_then(|e| e.get("ANTHROPIC_MODEL")).is_none());
// 可共享的非机密配置必须保留(含复数 _TOKENS 不被误剥)
assert_eq!(
env.and_then(|e| e.get("ENABLE_TOOL_SEARCH"))
.and_then(|v| v.as_str()),
Some("true")
);
assert_eq!(
env.and_then(|e| e.get("CLAUDE_CODE_MAX_OUTPUT_TOKENS"))
.and_then(|v| v.as_str()),
Some("8192")
);
assert_eq!(value.get("theme").and_then(|v| v.as_str()), Some("dark"));
assert_eq!(value.get("includeCoAuthoredBy"), Some(&json!(false)));
}
#[test]
fn validate_provider_settings_rejects_negative_cost_multiplier() {
let mut provider = Provider::with_id(
@@ -1318,6 +1706,72 @@ impl ProviderService {
.live_config_managed = Some(managed);
}
fn normalize_usage_script_credential_overrides(app_type: &AppType, provider: &mut Provider) {
let current_credentials = provider.resolve_usage_credentials(app_type);
let Some(usage_script) = provider
.meta
.as_mut()
.and_then(|meta| meta.usage_script.as_mut())
else {
return;
};
if usage_script.template_type.as_deref() == Some("token_plan") {
return;
}
if usage_script.api_key.as_deref().is_some_and(|api_key| {
Self::should_clear_usage_api_key_override(api_key, &current_credentials)
}) {
usage_script.api_key = None;
}
if usage_script.base_url.as_deref().is_some_and(|base_url| {
Self::should_clear_usage_base_url_override(base_url, &current_credentials)
}) {
usage_script.base_url = None;
}
}
fn should_clear_usage_api_key_override(
script_api_key: &str,
current_credentials: &(String, String),
) -> bool {
let candidate = script_api_key.trim();
if candidate.is_empty() {
return true;
}
let matches_provider_key = |api_key: &str| {
let api_key = api_key.trim();
!api_key.is_empty() && api_key == candidate
};
matches_provider_key(&current_credentials.1)
}
fn should_clear_usage_base_url_override(
script_base_url: &str,
current_credentials: &(String, String),
) -> bool {
let candidate = Self::normalize_usage_base_url_for_compare(script_base_url);
if candidate.is_empty() {
return true;
}
let matches_provider_base_url = |base_url: &str| {
let base_url = Self::normalize_usage_base_url_for_compare(base_url);
!base_url.is_empty() && base_url == candidate
};
matches_provider_base_url(&current_credentials.0)
}
fn normalize_usage_base_url_for_compare(base_url: &str) -> String {
base_url.trim().trim_end_matches('/').to_string()
}
/// List all providers for an app type
pub fn list(
state: &AppState,
@@ -1354,6 +1808,7 @@ impl ProviderService {
Self::normalize_provider_if_claude(&app_type, &mut provider);
Self::validate_provider_settings(&app_type, &provider)?;
normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?;
Self::normalize_usage_script_credential_overrides(&app_type, &mut provider);
if app_type.is_additive_mode() {
Self::set_provider_live_config_managed(&mut provider, add_to_live);
}
@@ -1408,6 +1863,7 @@ impl ProviderService {
Self::normalize_provider_if_claude(&app_type, &mut provider);
Self::validate_provider_settings(&app_type, &provider)?;
normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?;
Self::normalize_usage_script_credential_overrides(&app_type, &mut provider);
if provider_id_changed {
if !app_type.is_additive_mode() {
@@ -1896,6 +2352,17 @@ impl ProviderService {
// Only backfill when switching to a different provider
if let Ok(live_config) = read_live_settings(app_type.clone()) {
if let Some(mut current_provider) = providers.get(&current_id).cloned() {
// 切走前先把 live 里的可共享改动(含用户直接在应用内
// 装插件/加 hook/改偏好)同步进通用配置片段,再做剥离回填。
// 详见 sync_common_config_snippet_from_live 的文档。
Self::sync_common_config_snippet_from_live(
state,
&app_type,
&current_provider,
&live_config,
&mut result,
);
current_provider.settings_config =
strip_common_config_from_live_settings(
state.db.as_ref(),
@@ -2123,6 +2590,100 @@ impl ProviderService {
Self::migrate_legacy_common_config_usage(state, app_type, &snippet)
}
/// 切走某供应商前,把它 live 配置里的可共享部分重新提取并**整体替换**到
/// 通用配置片段,使在 live 应用里直接做的改动不会因切换而丢失。
///
/// 采用"整体重提取 + 替换"而非"只合并新增",是为了同时覆盖三种情况:
/// - **新增**:用户直接在应用里装了插件、加了 hook、改了 env/主题/权限等共享
/// 偏好,被捕获进通用配置,切到别的供应商也带得过去;
/// - **删除**:被删掉的键不在新提取结果里,于是从片段里消失、下次切换不会被
/// 重新注入——否则会出现"插件怎么删也删不掉"的反直觉 bug;
/// - **密钥安全**:提取器已剥掉 auth / model / endpoint,密钥永不进共享片段。
///
/// 之所以"整体替换"是安全的:每次写 live 都会把当前片段合并进去,所以切走时
/// 读到的 live 一定是"片段 + 本地改动"的超集,重提取只会丢掉用户真正删掉的键,
/// 不会误删其它供应商共享的内容。
///
/// **作用域**:仅 Claude。Codex 的 live 是 TOML 且端点藏在 `[model_providers]`
/// 表里(现有提取器不剥),自动同步会泄漏端点并与 modelCatalog / 统一会话桶 /
/// auth 还原逻辑冲突;Gemini 暂未纳入。两者如需支持应各自单独验证后再加。
///
/// 仅对**显式勾选"写入通用配置"**`meta.common_config_enabled == Some(true)`)的
/// 供应商生效;用户**显式清空**过片段(`_cleared`)时跳过,避免把用户主动清掉的
/// 配置又塞回来。所有失败均为非致命,只记 warning,绝不阻断切换。
fn sync_common_config_snippet_from_live(
state: &AppState,
app_type: &AppType,
provider: &Provider,
live_config: &Value,
result: &mut SwitchResult,
) {
// 作用域限定 Claude(见函数文档)。
if !matches!(app_type, AppType::Claude) {
return;
}
let opted_in = provider
.meta
.as_ref()
.and_then(|meta| meta.common_config_enabled)
== Some(true);
if !opted_in {
return;
}
match state.db.is_config_snippet_cleared(app_type.as_str()) {
Ok(true) => return, // 用户显式清空过通用配置,尊重其选择,不再自动塞回
Ok(false) => {}
Err(err) => {
log::warn!(
"Failed to read common config cleared flag for {}: {err}",
app_type.as_str()
);
return;
}
}
let new_snippet = match Self::extract_common_config_snippet_from_settings(
app_type.clone(),
live_config,
) {
Ok(snippet) => snippet,
Err(err) => {
log::warn!(
"Failed to extract common config from live for {} provider '{}': {err}",
app_type.as_str(),
provider.id
);
return;
}
};
// 未变化则跳过,避免无谓写库(不切 live 配置时这是常态路径)。
let current = state
.db
.get_config_snippet(app_type.as_str())
.ok()
.flatten();
if current.as_deref() == Some(new_snippet.as_str()) {
return;
}
if let Err(err) = state
.db
.set_config_snippet(app_type.as_str(), Some(new_snippet))
{
log::warn!(
"Failed to persist synced common config for {} provider '{}': {err}",
app_type.as_str(),
provider.id
);
result
.warnings
.push(format!("common_config_sync_failed:{}", provider.id));
}
}
/// Extract common config snippet from current provider
///
/// Extracts the current provider's configuration and removes provider-specific fields
@@ -2169,16 +2730,63 @@ impl ProviderService {
}
}
/// 判断一个 env / 顶层配置键名是否为凭据/机密:凡命中一律不得写入共享的
/// 通用配置片段。**故意从严**——多剥一个非机密键只是它不被共享(可恢复的小
/// 不便),漏剥一个凭据则会把密钥注入到每个供应商(不可恢复的泄漏)。因此用
/// 模式匹配覆盖整类,而非枚举具体名字(枚举永远会漏掉下一个 `*_API_KEY`)。
///
/// 覆盖:Anthropic / OpenRouter / Google / OpenAI / Gemini 等 `*_API_KEY`
/// Claude provider 的凭据见 `Provider::resolve_usage_credentials`,确实支持
/// `OPENROUTER_API_KEY` / `GOOGLE_API_KEY` 等回退)、各类 `*_AUTH_TOKEN` /
/// 单数 `*_TOKEN`、AWS Bedrock / Vertex 凭据、以及通用 secret / password /
/// 私钥命名。
fn is_sensitive_config_key(name: &str) -> bool {
let upper = name.to_ascii_uppercase();
// 单数 `_TOKEN` 命中 AWS_SESSION_TOKEN 等,但**不**误伤复数 `_TOKENS`
// CLAUDE_CODE_MAX_OUTPUT_TOKENS / MAX_THINKING_TOKENS 是正常可共享配置)。
const SENSITIVE_SUFFIXES: &[&str] = &[
"_API_KEY",
"_APIKEY",
"_AUTH_TOKEN",
"_TOKEN",
"_ACCESS_KEY",
"_ACCESS_KEY_ID",
"_KEY_ID",
"_PRIVATE_KEY",
];
const SENSITIVE_EXACT: &[&str] = &[
"APIKEY",
"API_KEY",
"TOKEN",
"SECRET",
"PASSWORD",
"CREDENTIALS",
];
// contains:覆盖 AWS_SECRET_ACCESS_KEY / *_CLIENT_SECRET /
// GOOGLE_APPLICATION_CREDENTIALS / AWS_BEARER_TOKEN_BEDROCK 等变体。
const SENSITIVE_CONTAINS: &[&str] = &[
"SECRET",
"PASSWORD",
"PASSWD",
"CREDENTIAL",
"PRIVATE_KEY",
"BEARER_TOKEN",
];
SENSITIVE_EXACT.contains(&upper.as_str())
|| SENSITIVE_SUFFIXES.iter().any(|s| upper.ends_with(s))
|| SENSITIVE_CONTAINS.iter().any(|c| upper.contains(c))
}
/// Extract common config for Claude (JSON format)
fn extract_claude_common_config(settings: &Value) -> Result<String, AppError> {
let mut config = settings.clone();
// Fields to exclude from common config
const ENV_EXCLUDES: &[&str] = &[
// Auth
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
// Models and Claude Code model-menu display names
// 供应商专属的**非机密**字段(模型 + 端点),不应共享。凭据/机密不在此列举,
// 改由 `is_sensitive_config_key`(模式匹配)统一剥离,新供应商的 `*_API_KEY`
// 等无需再手工补名单即可被覆盖。
const ENV_PROVIDER_SPECIFIC_EXCLUDES: &[&str] = &[
"ANTHROPIC_MODEL",
"ANTHROPIC_REASONING_MODEL", // legacy: 已废弃,但旧配置可能残留
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
@@ -2187,7 +2795,6 @@ impl ProviderService {
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME",
// Endpoint
"ANTHROPIC_BASE_URL",
];
@@ -2198,22 +2805,39 @@ impl ProviderService {
"smallFastModel",
];
// Remove env fields
// Remove env fields: provider-specific (models/endpoint) + 任何凭据键。
if let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) {
for key in ENV_EXCLUDES {
let sensitive: Vec<String> = env
.keys()
.filter(|k| Self::is_sensitive_config_key(k))
.cloned()
.collect();
for key in ENV_PROVIDER_SPECIFIC_EXCLUDES {
env.remove(*key);
}
for key in &sensitive {
env.remove(key);
}
// If env is empty after removal, remove the env object itself
if env.is_empty() {
config.as_object_mut().map(|obj| obj.remove("env"));
}
}
// Remove top-level fields
// Remove top-level fields: legacy model fields + 任何凭据键
// (例如非标准的顶层 apiKey / api_key / *_TOKEN)。
if let Some(obj) = config.as_object_mut() {
let sensitive: Vec<String> = obj
.keys()
.filter(|k| Self::is_sensitive_config_key(k))
.cloned()
.collect();
for key in TOP_LEVEL_EXCLUDES {
obj.remove(*key);
}
for key in &sensitive {
obj.remove(key);
}
}
// Check if result is empty
+21 -2
View File
@@ -2213,12 +2213,16 @@ impl ProxyService {
.get("auth")
.ok_or_else(|| "Codex 供应商缺少 auth 配置".to_string())?;
let config_str = effective_settings.get("config").and_then(|v| v.as_str());
let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format(
provider.meta.as_ref().and_then(|m| m.api_format.as_deref()),
);
crate::codex_config::write_codex_provider_live_with_catalog(
&effective_settings,
provider.category.as_deref(),
auth,
config_str,
profile,
)
.map_err(|e| format!("写入 Codex 配置失败: {e}"))?;
}
@@ -2512,12 +2516,16 @@ impl ProxyService {
.get("auth")
.ok_or_else(|| "Codex 配置缺少 auth 字段".to_string())?;
let config_str = config.get("config").and_then(|v| v.as_str());
let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format(
provider.meta.as_ref().and_then(|m| m.api_format.as_deref()),
);
crate::codex_config::write_codex_provider_live_with_catalog(
config,
provider.category.as_deref(),
auth,
config_str,
profile,
)
.map_err(|e| format!("写入 Codex 配置失败: {e}"))
}
@@ -2537,9 +2545,12 @@ impl ProxyService {
.filter(|auth| Self::codex_auth_has_proxy_placeholder(auth))
{
let config_str = config.get("config").and_then(|v| v.as_str()).unwrap_or("");
let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format(
provider.and_then(|p| p.meta.as_ref()?.api_format.as_deref()),
);
let prepared_config =
crate::codex_config::prepare_codex_live_config_text_with_optional_catalog(
config, config_str,
config, config_str, profile,
)
.map_err(|e| format!("写入 Codex 配置失败: {e}"))?;
let live_config =
@@ -2573,10 +2584,18 @@ impl ProxyService {
// living in the config's `experimental_bearer_token`). Computing it up here
// keeps every config-writing branch — write-auth, delete-auth, no-auth —
// consistent instead of letting the empty-auth path skip projection.
// Verbatim restore has no Provider in hand (we only have the stored
// backup config), so the catalog tool profile can't be recovered here.
// Default to ProxyChat: a restored native-direct backup keeps its inline
// modelCatalog but would not get apply_patch re-stripped until the next
// provider switch rewrites it via write_live_snapshot. Acceptable known
// limitation (restore-of-deleted-provider-backup only).
let prepared_cfg = config_str
.map(|cfg| {
crate::codex_config::prepare_codex_live_config_text_with_optional_catalog(
config, cfg,
config,
cfg,
crate::codex_config::CodexCatalogToolProfile::ProxyChat,
)
})
.transpose()
+67 -2
View File
@@ -2197,8 +2197,24 @@ fn strip_model_date_suffix(model_id: &str) -> Option<String> {
}
let (base, suffix) = model_id.rsplit_once('-')?;
(!base.is_empty() && suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_digit()))
.then(|| base.to_string())
if base.is_empty() || !suffix.chars().all(|c| c.is_ascii_digit()) {
return None;
}
// 8 位 YYYYMMDD(如 -20250615OpenAI / Claude / 通义千问等)。
if suffix.len() == 8 {
return Some(base.to_string());
}
// 6 位 YYMMDD(如 -260628;火山方舟 doubao-seed-*、部分国产厂商)。
// 6 位比 8 位更易误伤非日期尾巴(如 -123456 的版本号),故额外校验
// 月 01-12、日 01-31 才剥离;剥不动时退回 None 由上层精确匹配兜底。
if suffix.len() == 6 {
let month: u32 = suffix[2..4].parse().unwrap_or(0);
let day: u32 = suffix[4..6].parse().unwrap_or(0);
if (1..=12).contains(&month) && (1..=31).contains(&day) {
return Some(base.to_string());
}
}
None
}
fn strip_reasoning_effort_suffix(model_id: &str) -> Option<String> {
@@ -3859,6 +3875,55 @@ mod tests {
assert_eq!(strip_model_date_suffix("abc🚀12345678"), None);
}
#[test]
fn test_strip_model_date_suffix_handles_six_digit_yymmdd() {
// 火山方舟 6 位 YYMMDD 后缀应被剥离(doubao 全系都用这种格式)。
assert_eq!(
strip_model_date_suffix("doubao-seed-2-1-pro-260628").as_deref(),
Some("doubao-seed-2-1-pro")
);
assert_eq!(
strip_model_date_suffix("doubao-seed-1-6-250615").as_deref(),
Some("doubao-seed-1-6")
);
// 8 位 YYYYMMDD 仍照旧剥离。
assert_eq!(
strip_model_date_suffix("claude-3-5-sonnet-20241022").as_deref(),
Some("claude-3-5-sonnet")
);
// 月/日非法的 6 位尾巴(版本号等)不剥离,避免误伤。
assert_eq!(strip_model_date_suffix("foo-bar-123456"), None); // 月=34
assert_eq!(strip_model_date_suffix("widget-209900"), None); // 月=99
assert_eq!(strip_model_date_suffix("gizmo-251200"), None); // 日=00
}
#[test]
fn test_pricing_resolves_volcengine_dated_model_to_bare_seed_row() -> Result<(), AppError> {
// 回归:火山真实用量带 6 位日期后缀(doubao-seed-2-1-pro-260628),
// 必须能归一化命中定价表里的裸名 seed 行(doubao-seed-2-1-pro),否则成本显示 $0。
let db = Database::memory()?;
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT OR REPLACE INTO model_pricing (
model_id, display_name, input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
) VALUES ('doubao-seed-2-1-pro', 'Doubao Seed 2.1 Pro', '0.84', '4.2', '0.17', '0')",
[],
)?;
let row = find_model_pricing_row(&conn, "doubao-seed-2-1-pro-260628")?;
assert!(
row.is_some(),
"带日期的火山模型应通过 6 位日期剥离命中裸名定价行"
);
let (input, output, ..) = row.unwrap();
assert_eq!(input, "0.84");
assert_eq!(output, "4.2");
Ok(())
}
#[test]
fn test_prefix_pricing_does_not_match_short_base_model_to_variant() -> Result<(), AppError> {
let db = Database::memory()?;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.16.3",
"version": "3.16.4",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
+361
View File
@@ -1696,6 +1696,367 @@ fn provider_service_switch_claude_updates_live_and_state() {
);
}
/// 切走勾选了通用配置的 Claude 供应商时,应把它 live 里新增的可共享键
/// (用户直接在应用内装插件/改偏好)捕获进通用配置片段,并带到下一个供应商。
#[test]
fn switch_claude_syncs_new_shared_keys_from_live_into_common_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let settings_path = get_claude_settings_path();
if let Some(parent) = settings_path.parent() {
std::fs::create_dir_all(parent).expect("create claude settings dir");
}
// A 的 live = A 私有密钥(含非 Anthropic 的 OpenRouter 凭据)+ 已共享的 theme
// + 用户刚在应用内新增的 enableAllProjectMcpServers
let live = json!({
"env": { "ANTHROPIC_API_KEY": "a-key", "OPENROUTER_API_KEY": "sk-or-leak" },
"theme": "dark",
"enableAllProjectMcpServers": true
});
std::fs::write(
&settings_path,
serde_json::to_string_pretty(&live).expect("serialize live"),
)
.expect("seed claude live config");
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Claude)
.expect("claude manager");
manager.current = "a".to_string();
let mut provider_a = Provider::with_id(
"a".to_string(),
"A".to_string(),
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
None,
);
provider_a.meta = Some(ProviderMeta {
common_config_enabled: Some(true),
..Default::default()
});
manager.providers.insert("a".to_string(), provider_a);
let mut provider_b = Provider::with_id(
"b".to_string(),
"B".to_string(),
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
None,
);
provider_b.meta = Some(ProviderMeta {
common_config_enabled: Some(true),
..Default::default()
});
manager.providers.insert("b".to_string(), provider_b);
}
let state = create_test_state_with_config(&config).expect("create test state");
state
.db
.set_config_snippet(
AppType::Claude.as_str(),
Some(r#"{"theme":"dark"}"#.to_string()),
)
.expect("seed common config snippet");
ProviderService::switch(&state, AppType::Claude, "b").expect("switch should succeed");
// 片段应捕获到新增键,并保留已有共享键,且绝不含密钥
let snippet = state
.db
.get_config_snippet(AppType::Claude.as_str())
.expect("read snippet")
.expect("snippet present");
let snippet_value: serde_json::Value =
serde_json::from_str(&snippet).expect("snippet is valid JSON");
assert_eq!(
snippet_value.get("enableAllProjectMcpServers"),
Some(&json!(true)),
"newly added shared key should be captured into common config"
);
assert_eq!(
snippet_value.get("theme").and_then(|v| v.as_str()),
Some("dark"),
"previously shared key should be preserved"
);
assert!(
snippet_value
.get("env")
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
.is_none(),
"secrets must never leak into the shared snippet"
);
assert!(
snippet_value
.get("env")
.and_then(|env| env.get("OPENROUTER_API_KEY"))
.is_none(),
"non-Anthropic Claude credentials must never leak into the shared snippet"
);
// 新增键应通过通用配置带到 B 的 live
let live_after: serde_json::Value =
read_json_file(&settings_path).expect("read live after switch");
assert_eq!(
live_after.get("enableAllProjectMcpServers"),
Some(&json!(true)),
"shared key should propagate to the next provider's live config"
);
assert!(
live_after
.get("env")
.and_then(|env| env.get("OPENROUTER_API_KEY"))
.is_none(),
"leaked credential must not be injected into the next provider's live"
);
assert_eq!(
live_after
.get("env")
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
.and_then(|v| v.as_str()),
Some("b-key"),
"live should reflect new provider's own auth"
);
}
/// 用户在应用内删掉一个已共享的键后,切换应把删除同步进通用配置,
/// 且不会在切到下一个供应商时被重新注入(否则会"删不掉")。
#[test]
fn switch_claude_syncs_deletions_from_live_into_common_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let settings_path = get_claude_settings_path();
if let Some(parent) = settings_path.parent() {
std::fs::create_dir_all(parent).expect("create claude settings dir");
}
// live 里 theme 还在,但用户已删掉 enableAllProjectMcpServers
let live = json!({
"env": { "ANTHROPIC_API_KEY": "a-key" },
"theme": "dark"
});
std::fs::write(
&settings_path,
serde_json::to_string_pretty(&live).expect("serialize live"),
)
.expect("seed claude live config");
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Claude)
.expect("claude manager");
manager.current = "a".to_string();
let mut provider_a = Provider::with_id(
"a".to_string(),
"A".to_string(),
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
None,
);
provider_a.meta = Some(ProviderMeta {
common_config_enabled: Some(true),
..Default::default()
});
manager.providers.insert("a".to_string(), provider_a);
let mut provider_b = Provider::with_id(
"b".to_string(),
"B".to_string(),
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
None,
);
provider_b.meta = Some(ProviderMeta {
common_config_enabled: Some(true),
..Default::default()
});
manager.providers.insert("b".to_string(), provider_b);
}
let state = create_test_state_with_config(&config).expect("create test state");
// 片段里仍残留 enableAllProjectMcpServers(上次共享的)
state
.db
.set_config_snippet(
AppType::Claude.as_str(),
Some(r#"{"theme":"dark","enableAllProjectMcpServers":true}"#.to_string()),
)
.expect("seed common config snippet");
ProviderService::switch(&state, AppType::Claude, "b").expect("switch should succeed");
let snippet = state
.db
.get_config_snippet(AppType::Claude.as_str())
.expect("read snippet")
.expect("snippet present");
let snippet_value: serde_json::Value =
serde_json::from_str(&snippet).expect("snippet is valid JSON");
assert!(
snippet_value.get("enableAllProjectMcpServers").is_none(),
"deleted key should be removed from common config"
);
assert_eq!(
snippet_value.get("theme").and_then(|v| v.as_str()),
Some("dark"),
"untouched shared key should remain"
);
// 切到 B 后 live 不应再出现被删除的键
let live_after: serde_json::Value =
read_json_file(&settings_path).expect("read live after switch");
assert!(
live_after.get("enableAllProjectMcpServers").is_none(),
"deleted shared key must not be re-injected into the next provider"
);
}
/// 未勾选"写入通用配置"的供应商,其 live 改动不应自动污染通用配置片段。
#[test]
fn switch_claude_does_not_sync_common_config_for_opted_out_provider() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let settings_path = get_claude_settings_path();
if let Some(parent) = settings_path.parent() {
std::fs::create_dir_all(parent).expect("create claude settings dir");
}
let live = json!({
"env": { "ANTHROPIC_API_KEY": "a-key" },
"providerSpecific": "x"
});
std::fs::write(
&settings_path,
serde_json::to_string_pretty(&live).expect("serialize live"),
)
.expect("seed claude live config");
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Claude)
.expect("claude manager");
manager.current = "a".to_string();
// A 未勾选通用配置(meta = None
manager.providers.insert(
"a".to_string(),
Provider::with_id(
"a".to_string(),
"A".to_string(),
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
None,
),
);
manager.providers.insert(
"b".to_string(),
Provider::with_id(
"b".to_string(),
"B".to_string(),
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
None,
),
);
}
let state = create_test_state_with_config(&config).expect("create test state");
state
.db
.set_config_snippet(
AppType::Claude.as_str(),
Some(r#"{"theme":"dark"}"#.to_string()),
)
.expect("seed common config snippet");
ProviderService::switch(&state, AppType::Claude, "b").expect("switch should succeed");
let snippet = state
.db
.get_config_snippet(AppType::Claude.as_str())
.expect("read snippet")
.expect("snippet present");
let snippet_value: serde_json::Value =
serde_json::from_str(&snippet).expect("snippet is valid JSON");
assert!(
snippet_value.get("providerSpecific").is_none(),
"opted-out provider's live changes must not pollute the shared snippet"
);
assert_eq!(
snippet_value.get("theme").and_then(|v| v.as_str()),
Some("dark"),
"snippet should stay unchanged for opted-out providers"
);
}
/// 用户显式清空过通用配置(_cleared)后,切换不应把片段重新塞回来。
#[test]
fn switch_claude_respects_explicitly_cleared_common_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let settings_path = get_claude_settings_path();
if let Some(parent) = settings_path.parent() {
std::fs::create_dir_all(parent).expect("create claude settings dir");
}
let live = json!({
"env": { "ANTHROPIC_API_KEY": "a-key" },
"theme": "dark"
});
std::fs::write(
&settings_path,
serde_json::to_string_pretty(&live).expect("serialize live"),
)
.expect("seed claude live config");
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Claude)
.expect("claude manager");
manager.current = "a".to_string();
let mut provider_a = Provider::with_id(
"a".to_string(),
"A".to_string(),
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
None,
);
provider_a.meta = Some(ProviderMeta {
common_config_enabled: Some(true),
..Default::default()
});
manager.providers.insert("a".to_string(), provider_a);
let mut provider_b = Provider::with_id(
"b".to_string(),
"B".to_string(),
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
None,
);
provider_b.meta = Some(ProviderMeta {
common_config_enabled: Some(true),
..Default::default()
});
manager.providers.insert("b".to_string(), provider_b);
}
let state = create_test_state_with_config(&config).expect("create test state");
state
.db
.set_config_snippet_cleared(AppType::Claude.as_str(), true)
.expect("mark snippet cleared");
ProviderService::switch(&state, AppType::Claude, "b").expect("switch should succeed");
assert!(
state
.db
.get_config_snippet(AppType::Claude.as_str())
.expect("read snippet")
.is_none(),
"explicitly cleared snippet must not be resurrected by switch-away sync"
);
}
#[test]
fn provider_service_switch_missing_provider_returns_error() {
let _guard = test_mutex().lock().expect("acquire test mutex");