mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-01 12:22:09 +08:00
Merge remote-tracking branch 'origin/main' into feature/managed-oauth-account-selector
This commit is contained in:
Generated
+2
-1
@@ -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]]
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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 登录态对请求体启用 zstd(Compression::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.4:codings 按**应用顺序**列出,故解压须**反向**(最后应用的先解)。
|
||||
/// 返回 `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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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}")))?;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 结构,在新架构中为空
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ¤t_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, ¤t_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(¤t_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(¤t_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(¤t_id).cloned() {
|
||||
// 切走前先把 live 里的可共享改动(含用户直接在应用内
|
||||
// 装插件/加 hook/改偏好)同步进通用配置片段,再做剥离回填。
|
||||
// 详见 sync_common_config_snippet_from_live 的文档。
|
||||
Self::sync_common_config_snippet_from_live(
|
||||
state,
|
||||
&app_type,
|
||||
¤t_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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(如 -20250615;OpenAI / 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,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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user