diff --git a/docs/pi-native-contract-zh.md b/docs/pi-native-contract-zh.md index d40fe3c78..84fd37843 100644 --- a/docs/pi-native-contract-zh.md +++ b/docs/pi-native-contract-zh.md @@ -28,7 +28,7 @@ - 供应商级 `name`、`baseUrl`、`apiKey`、`api`、`headers` - 模型级 `id`、`name`、`reasoning`、`input`、`contextWindow`、`maxTokens` -已有配置中的其他字段原样保留。供应商是否可管理只取决于节点是否显式存在于 `models.json.providers`:`anthropic`、`openai`、`deepseek` 等 Pi 内置 ID,以及带有未知字段的节点,都按普通显式配置同步。CC Switch 不把 Pi 运行时合并出的内置模型复制回配置,也不解析或执行 `apiKey`、Header 中的环境变量和命令表达式。请求仍由 Pi 自己发出。 +已有配置中的其他字段原样保留。供应商是否可管理只取决于节点是否显式存在于 `models.json.providers`:`anthropic`、`openai`、`deepseek` 等 Pi 内置 ID,以及带有未知字段的节点,都按普通显式配置同步。CC Switch 不把 Pi 运行时合并出的内置模型复制回配置,也不执行 `apiKey`、Header 中的命令表达式。请求仍由 Pi 自己发出;只有 CC Switch 自己发的用量查询会按 Pi 的规则展开 `apiKey` 里的环境变量引用(`$NAME` / `${NAME}`),展开结果不写回配置,`!command` 和未设置的变量都按没有 Key 处理。 Pi 在全局设置中保存的当前供应商和模型不进入供应商列表状态。启用供应商只把条目加入 `models.json`,不会写 `defaultProvider` 或 `defaultModel`。移除或删除全局默认供应商时,前端在原有确认框内给出非阻塞提醒,后端允许继续且不改写默认项。编辑显式供应商同样不修改当前供应商或模型;失效引用和回退由 Pi 原生处理。 diff --git a/src-tauri/src/pi_config/mod.rs b/src-tauri/src/pi_config/mod.rs index 947e1b1d4..e92876fc1 100644 --- a/src-tauri/src/pi_config/mod.rs +++ b/src-tauri/src/pi_config/mod.rs @@ -276,6 +276,73 @@ pub(crate) fn provider_base_url(config: &Value) -> Result { .ok_or_else(|| AppError::InvalidInput("Pi provider has no request URL".to_string())) } +/// The key CC Switch's own usage queries should send for a Pi `apiKey` value. +/// +/// Pi resolves `$NAME` / `${NAME}` from the environment (`$$` and `$!` are literal +/// `$` and `!`) and runs values starting with `!` as shell commands. Usage queries +/// follow the same environment rules so a reference is never sent as the key +/// itself. Commands are not run here, and a referenced variable that is unset or +/// empty makes the whole value unresolved, as in Pi; both give an empty key. +pub(crate) fn resolve_usage_api_key(value: &str) -> String { + resolve_usage_api_key_with(value, |name| std::env::var(name).ok()) +} + +fn resolve_usage_api_key_with(value: &str, env: impl Fn(&str) -> Option) -> String { + if value.starts_with('!') { + return String::new(); + } + let is_name_start = |ch: char| ch == '_' || ch.is_ascii_alphabetic(); + let is_name_char = |ch: char| ch == '_' || ch.is_ascii_alphanumeric(); + let lookup = |name: &str| env(name).filter(|value| !value.is_empty()); + + let mut resolved = String::new(); + let mut rest = value; + while let Some(dollar) = rest.find('$') { + resolved.push_str(&rest[..dollar]); + let after = &rest[dollar + 1..]; + match after.chars().next() { + Some(ch @ ('$' | '!')) => { + resolved.push(ch); + rest = &after[1..]; + } + Some('{') => match after.find('}') { + Some(end) => { + let name = &after[1..end]; + let valid = name.starts_with(is_name_start) && name.chars().all(is_name_char); + if valid { + match lookup(name) { + Some(found) => resolved.push_str(&found), + None => return String::new(), + } + } else { + resolved.push('$'); + resolved.push_str(&after[..=end]); + } + rest = &after[end + 1..]; + } + None => { + resolved.push('$'); + rest = after; + } + }, + Some(ch) if is_name_start(ch) => { + let len = after.find(|ch| !is_name_char(ch)).unwrap_or(after.len()); + match lookup(&after[..len]) { + Some(found) => resolved.push_str(&found), + None => return String::new(), + } + rest = &after[len..]; + } + _ => { + resolved.push('$'); + rest = after; + } + } + } + resolved.push_str(rest); + resolved +} + fn lock_models_file() -> Result, AppError> { MODELS_FILE_LOCK .lock() @@ -528,6 +595,31 @@ mod tests { }) } + #[test] + fn usage_api_key_resolves_pi_environment_references() { + let env = |name: &str| match name { + "KEY" => Some("sk-env".to_string()), + "PART" => Some("abc".to_string()), + "EMPTY" => Some(String::new()), + _ => None, + }; + let resolve = |value: &str| resolve_usage_api_key_with(value, env); + + assert_eq!(resolve("sk-literal"), "sk-literal"); + assert_eq!(resolve("$KEY"), "sk-env"); + assert_eq!(resolve("${KEY}"), "sk-env"); + assert_eq!(resolve("pre-${PART}-$PART.x"), "pre-abc-abc.x"); + assert_eq!(resolve("a$$b$!c"), "a$b!c"); + assert_eq!(resolve("cost$5 ${1X} ${open"), "cost$5 ${1X} ${open"); + // Unset or empty variables leave the value unresolved, as in Pi. + assert_eq!(resolve("$MISSING"), ""); + assert_eq!(resolve("${EMPTY}"), ""); + assert_eq!(resolve("$KEY-$MISSING"), ""); + // Commands are Pi's to run, never sent as the key. + assert_eq!(resolve("!security find-generic-password -w"), ""); + assert_eq!(resolve(""), ""); + } + #[test] fn provider_node_accepts_unknown_native_fields() { let mut value = provider(); diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 8c68b4a0e..b9ccfe834 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -226,10 +226,11 @@ impl Provider { str_at(settings.get("baseUrl")), str_at(settings.get("apiKey")), ), - // Pi custom providers use the native models.json field names. + // Pi custom providers use the native models.json field names. Pi expands + // `$NAME` references in `apiKey`, so resolve them before sending (#8822). AppType::Pi => ( crate::pi_config::provider_base_url(settings).unwrap_or_default(), - str_at(settings.get("apiKey")), + crate::pi_config::resolve_usage_api_key(&str_at(settings.get("apiKey"))), ), // OpenCode (OMO) nests credentials under `options` (the SDK options object). AppType::OpenCode | AppType::Mcode => { @@ -1737,6 +1738,17 @@ mod tests { ); } + #[test] + fn resolve_credentials_pi_never_sends_key_references_literally() { + for reference in ["$CC_SWITCH_TEST_UNSET_8822", "!echo sk-from-command"] { + let p = provider_with(json!({ + "baseUrl": "https://api.example.com/v1", + "apiKey": reference, + })); + assert_eq!(p.resolve_usage_credentials(&AppType::Pi).1, ""); + } + } + #[test] fn resolve_credentials_opencode_options() { // OpenCode (OMO) nests creds under options.{baseURL,apiKey}; useOpencodeFormState