fix(pi): resolve $NAME references in apiKey for usage queries

Pi lets a models.json provider's apiKey be "$NAME" / "${NAME}" (read from
the environment, with "$$" and "$!" as literal "$" and "!") or "!command".
CC Switch's own usage queries (Coding Plan, balance, usage-script
{{apiKey}}) read the Pi apiKey verbatim through resolve_usage_credentials,
so the provider received "Bearer $COMMAND_API_KEY" and answered 401.

Resolve the value with Pi's template rules before sending it. A referenced
variable that is unset or empty leaves the value unresolved, as in Pi, and
"!command" values are still never run by CC Switch; both now give an empty
key instead of sending the reference as the credential. The resolved key is
only used for the outgoing request and is never written back.

Refs #8822
This commit is contained in:
Jason committed 2026-10-24 22:56:15 +08:00
1 parent 442df1db70
commit dcdcbcb473
3 files changed
+107 -3

No files matched your search

+1 -1
View File
@@ -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 原生处理。
+92
View File
@@ -276,6 +276,73 @@ pub(crate) fn provider_base_url(config: &Value) -> Result<String, AppError> {
.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>) -> 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<MutexGuard<'static, ()>, 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();
+14 -2
View File
@@ -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