mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-25 17:22:27 +08:00
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:
1 parent
442df1db70
commit
dcdcbcb473
3 files changed
+107
-3
No files matched your search
@@ -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 原生处理。
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user