mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 03:32:25 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8e4cae41b | |||
| eb356e15bd | |||
| f38722a440 | |||
| 13ea497ab0 | |||
| 8383076791 | |||
| bc180a3d9d | |||
| c7a2bff78b | |||
| d52ab6c5f4 | |||
| 0f3991efc3 |
@@ -19,7 +19,7 @@ jobs:
|
|||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "20"
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
|
|
||||||
|
|||||||
@@ -2237,6 +2237,7 @@ impl Database {
|
|||||||
"0",
|
"0",
|
||||||
),
|
),
|
||||||
// Qwen 系列 (阿里巴巴)
|
// Qwen 系列 (阿里巴巴)
|
||||||
|
("qwen3.8-max", "Qwen3.8 Max", "2", "6", "0.25", "2.50"),
|
||||||
("qwen3.7-max", "Qwen3.7 Max", "2.50", "7.50", "0.25", "0"),
|
("qwen3.7-max", "Qwen3.7 Max", "2.50", "7.50", "0.25", "0"),
|
||||||
("qwen3.7-plus", "Qwen3.7 Plus", "0.40", "1.60", "0.08", "0"),
|
("qwen3.7-plus", "Qwen3.7 Plus", "0.40", "1.60", "0.08", "0"),
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -33,13 +33,29 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
|||||||
AppType::Claude => "CLAUDE.md",
|
AppType::Claude => "CLAUDE.md",
|
||||||
AppType::Codex => "AGENTS.md",
|
AppType::Codex => "AGENTS.md",
|
||||||
AppType::Gemini => "GEMINI.md",
|
AppType::Gemini => "GEMINI.md",
|
||||||
AppType::GrokBuild | AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md",
|
AppType::GrokBuild | AppType::OpenCode | AppType::OpenClaw => "AGENTS.md",
|
||||||
|
AppType::Hermes => "SOUL.md",
|
||||||
AppType::ClaudeDesktop => unreachable!("handled above"),
|
AppType::ClaudeDesktop => unreachable!("handled above"),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(base_dir.join(filename))
|
Ok(base_dir.join(filename))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hermes_prompt_file_uses_soul_md() {
|
||||||
|
let path = prompt_file_path(&AppType::Hermes).expect("Hermes prompt path");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
path.file_name().and_then(|name| name.to_str()),
|
||||||
|
Some("SOUL.md")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn get_base_dir_with_fallback(
|
fn get_base_dir_with_fallback(
|
||||||
primary_path: PathBuf,
|
primary_path: PathBuf,
|
||||||
fallback_dir: &str,
|
fallback_dir: &str,
|
||||||
|
|||||||
@@ -87,6 +87,17 @@ impl Provider {
|
|||||||
|| self.claude_base_url_contains("chatgpt.com/backend-api/codex")
|
|| self.claude_base_url_contains("chatgpt.com/backend-api/codex")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the provider form's "auth field" was explicitly set to
|
||||||
|
/// ANTHROPIC_API_KEY. The form only persists `meta.apiKeyField` for the
|
||||||
|
/// non-default choice, so `None` means the default ANTHROPIC_AUTH_TOKEN.
|
||||||
|
pub fn claude_uses_api_key_field(&self) -> bool {
|
||||||
|
self.meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.api_key_field.as_deref())
|
||||||
|
.map(|field| field.eq_ignore_ascii_case("ANTHROPIC_API_KEY"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
fn provider_type(&self) -> Option<&str> {
|
fn provider_type(&self) -> Option<&str> {
|
||||||
self.meta.as_ref().and_then(|m| m.provider_type.as_deref())
|
self.meta.as_ref().and_then(|m| m.provider_type.as_deref())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1183,6 +1183,15 @@ impl RequestForwarder {
|
|||||||
super::providers::copilot_model_map::apply_copilot_model_normalization(mapped_body);
|
super::providers::copilot_model_map::apply_copilot_model_normalization(mapped_body);
|
||||||
self.apply_copilot_live_model_resolution(provider, &mut mapped_body)
|
self.apply_copilot_live_model_resolution(provider, &mut mapped_body)
|
||||||
.await;
|
.await;
|
||||||
|
// Strip the [1M] context marker after Copilot normalization/resolve.
|
||||||
|
// A user's mapped value (e.g. "gpt-5.6-sol[1M]") carries [1M] as a
|
||||||
|
// Claude Code context-capability declaration that upstream APIs reject
|
||||||
|
// as part of the model name. The preceding normalization step already
|
||||||
|
// rewrites claude-xxx[1M] into the "-1m" dash form Copilot accepts, and
|
||||||
|
// the strip helper only touches the "[1m]" bracket form, so "-1m"
|
||||||
|
// variants pass through unchanged.
|
||||||
|
mapped_body =
|
||||||
|
super::model_mapper::strip_one_m_suffix_for_upstream_from_body(mapped_body);
|
||||||
} else if !codex_responses_to_anthropic {
|
} else if !codex_responses_to_anthropic {
|
||||||
// Skip on the Codex→Anthropic path: stripping [1m] here would break both the
|
// Skip on the Codex→Anthropic path: stripping [1m] here would break both the
|
||||||
// model-catalog match (apply_codex_upstream_model) and the transform's own
|
// model-catalog match (apply_codex_upstream_model) and the transform's own
|
||||||
|
|||||||
@@ -95,9 +95,16 @@ impl ProxyService {
|
|||||||
let auth_policy = if provider.uses_managed_account_auth() {
|
let auth_policy = if provider.uses_managed_account_auth() {
|
||||||
// Codex 系(含仅凭 base_url 识别、无 provider_type meta 的)必须保留
|
// Codex 系(含仅凭 base_url 识别、无 provider_type meta 的)必须保留
|
||||||
// ANTHROPIC_AUTH_TOKEN 占位符:Claude Code 缺该键会弹登录提示(#3784)。
|
// ANTHROPIC_AUTH_TOKEN 占位符:Claude Code 缺该键会弹登录提示(#3784)。
|
||||||
// Copilot 维持仅 API_KEY 占位,避免与 /login 管理的 key 冲突(#1049)。
|
// Copilot 默认同样注入 AUTH_TOKEN 占位符:Claude Code(实测 2.1.220)
|
||||||
|
// 对 ANTHROPIC_API_KEY 会弹"是否使用该自定义 key"确认框且默认
|
||||||
|
// "No (recommended)",按默认走后占位符被忽略、落入 Not logged in
|
||||||
|
// (并非 sk-ant-* 格式校验——headless 下占位符原样出站);AUTH_TOKEN
|
||||||
|
// 作为网关 Bearer 被直接信任,零弹窗。仅当供应商表单显式选择了
|
||||||
|
// ANTHROPIC_API_KEY(meta.apiKeyField)时才保留 API_KEY 占位,以规避
|
||||||
|
// 与 /login 管理的 key 冲突(#1049)。
|
||||||
ClaudeTakeoverAuthPolicy::ManagedAccount {
|
ClaudeTakeoverAuthPolicy::ManagedAccount {
|
||||||
keep_auth_token: !provider.is_github_copilot(),
|
keep_auth_token: !provider.is_github_copilot()
|
||||||
|
|| !provider.claude_uses_api_key_field(),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken
|
ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken
|
||||||
@@ -197,7 +204,10 @@ impl ProxyService {
|
|||||||
// - Codex 系保留 AUTH_TOKEN:缺该键 Claude Code 会弹登录提示(#3784)。
|
// - Codex 系保留 AUTH_TOKEN:缺该键 Claude Code 会弹登录提示(#3784)。
|
||||||
// 无条件注入而非"已存在才保留":热切换路径传入的是 provider
|
// 无条件注入而非"已存在才保留":热切换路径传入的是 provider
|
||||||
// settings(预设不含该键),且旧版接管已把存量用户 live 中的键删光。
|
// settings(预设不含该键),且旧版接管已把存量用户 live 中的键删光。
|
||||||
// - Copilot 仅 API_KEY:避免与 /login 管理的 key 冲突(#1049)。
|
// - Copilot 默认 AUTH_TOKEN:API_KEY 占位符会触发 Claude Code 的
|
||||||
|
// 自定义 key 确认框(默认 "No (recommended)"),按默认走即
|
||||||
|
// Not logged in;仅当表单显式选择了 ANTHROPIC_API_KEY 时才用
|
||||||
|
// API_KEY 占位以规避 /login key 冲突(#1049)。
|
||||||
if keep_auth_token {
|
if keep_auth_token {
|
||||||
env.insert(
|
env.insert(
|
||||||
"ANTHROPIC_AUTH_TOKEN".to_string(),
|
"ANTHROPIC_AUTH_TOKEN".to_string(),
|
||||||
@@ -3304,7 +3314,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn managed_account_claude_takeover_uses_api_key_placeholder() {
|
fn managed_account_claude_takeover_uses_auth_token_placeholder() {
|
||||||
let mut provider = Provider::with_id(
|
let mut provider = Provider::with_id(
|
||||||
"copilot".to_string(),
|
"copilot".to_string(),
|
||||||
"GitHub Copilot".to_string(),
|
"GitHub Copilot".to_string(),
|
||||||
@@ -3333,13 +3343,13 @@ mod tests {
|
|||||||
.and_then(|value| value.as_object())
|
.and_then(|value| value.as_object())
|
||||||
.expect("env should exist");
|
.expect("env should exist");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
env.get("ANTHROPIC_API_KEY")
|
env.get("ANTHROPIC_AUTH_TOKEN")
|
||||||
.and_then(|value| value.as_str()),
|
.and_then(|value| value.as_str()),
|
||||||
Some(PROXY_TOKEN_PLACEHOLDER)
|
Some(PROXY_TOKEN_PLACEHOLDER)
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
env.get("ANTHROPIC_AUTH_TOKEN").is_none(),
|
env.get("ANTHROPIC_API_KEY").is_none(),
|
||||||
"managed OAuth providers should avoid Claude Auth Token login semantics"
|
"API_KEY placeholders trigger Claude Code's custom-key approval prompt (defaults to No), landing users in Not logged in"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3421,8 +3431,8 @@ mod tests {
|
|||||||
"CLAUDE_CODE_SUBAGENT_MODEL",
|
"CLAUDE_CODE_SUBAGENT_MODEL",
|
||||||
Some("claude-sonnet-4.6[1M]"),
|
Some("claude-sonnet-4.6[1M]"),
|
||||||
);
|
);
|
||||||
assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER));
|
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", Some(PROXY_TOKEN_PLACEHOLDER));
|
||||||
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", None);
|
assert_env_str(env, "ANTHROPIC_API_KEY", None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3675,7 +3685,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn managed_account_claude_takeover_copilot_removes_stale_auth_token() {
|
fn managed_account_claude_takeover_copilot_defaults_to_auth_token() {
|
||||||
let mut provider = Provider::with_id(
|
let mut provider = Provider::with_id(
|
||||||
"copilot".to_string(),
|
"copilot".to_string(),
|
||||||
"GitHub Copilot".to_string(),
|
"GitHub Copilot".to_string(),
|
||||||
@@ -3691,6 +3701,48 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let mut live_config = json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://stale.example.com",
|
||||||
|
"ANTHROPIC_AUTH_TOKEN": "stale-token",
|
||||||
|
"ANTHROPIC_API_KEY": "stale-key"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ProxyService::apply_claude_takeover_fields_for_provider(
|
||||||
|
&mut live_config,
|
||||||
|
"http://127.0.0.1:15721",
|
||||||
|
&provider,
|
||||||
|
);
|
||||||
|
|
||||||
|
let env = live_config
|
||||||
|
.get("env")
|
||||||
|
.and_then(|value| value.as_object())
|
||||||
|
.expect("env should exist");
|
||||||
|
// Default Copilot takeover injects AUTH_TOKEN: the API_KEY placeholder
|
||||||
|
// triggers Claude Code's custom-key approval prompt (defaults to
|
||||||
|
// "No (recommended)"), which lands users in "Not logged in".
|
||||||
|
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", Some(PROXY_TOKEN_PLACEHOLDER));
|
||||||
|
assert_env_str(env, "ANTHROPIC_API_KEY", None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn managed_account_claude_takeover_copilot_honors_api_key_field_choice() {
|
||||||
|
let mut provider = Provider::with_id(
|
||||||
|
"copilot".to_string(),
|
||||||
|
"GitHub Copilot".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
provider.meta = Some(ProviderMeta {
|
||||||
|
provider_type: Some("github_copilot".to_string()),
|
||||||
|
api_key_field: Some("ANTHROPIC_API_KEY".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
let mut live_config = json!({
|
let mut live_config = json!({
|
||||||
"env": {
|
"env": {
|
||||||
"ANTHROPIC_BASE_URL": "https://stale.example.com",
|
"ANTHROPIC_BASE_URL": "https://stale.example.com",
|
||||||
@@ -3707,6 +3759,8 @@ mod tests {
|
|||||||
.get("env")
|
.get("env")
|
||||||
.and_then(|value| value.as_object())
|
.and_then(|value| value.as_object())
|
||||||
.expect("env should exist");
|
.expect("env should exist");
|
||||||
|
// Explicit API-key-field choice keeps the API_KEY placeholder to avoid
|
||||||
|
// conflicting with the /login-managed key (#1049).
|
||||||
assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER));
|
assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER));
|
||||||
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", None);
|
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", None);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2374,31 +2374,38 @@ impl SkillService {
|
|||||||
|
|
||||||
/// 将 discoverable skill 的目录信息重新解析为解压目录中的真实源目录。
|
/// 将 discoverable skill 的目录信息重新解析为解压目录中的真实源目录。
|
||||||
///
|
///
|
||||||
/// 兼容三种情况:
|
/// **核心原则:返回的目录必定含 `SKILL.md`**(以 SKILL.md 为锚点)。解析顺序:
|
||||||
/// 1. `skills/foo` 这类直接相对路径;
|
/// 1. 直接相对路径命中(如 `skills/foo`),校验含 `SKILL.md`——明确路径优先;
|
||||||
/// 2. 仅持有安装名 `foo`,需要在仓库中递归查找真实目录;
|
/// 2. 按安装名递归查找名字匹配 **且** 含 `SKILL.md` 的目录;
|
||||||
/// 3. 仓库根目录本身就是 skill,此时回退到解压根目录。
|
/// 3. 兜底:仓库根本身含 `SKILL.md`。
|
||||||
fn resolve_skill_source_dir(root: &Path, raw_directory: &str) -> Option<PathBuf> {
|
fn resolve_skill_source_dir(root: &Path, raw_directory: &str) -> Option<PathBuf> {
|
||||||
let source_rel = Self::sanitize_skill_source_path(raw_directory)?;
|
let source_rel = Self::sanitize_skill_source_path(raw_directory)?;
|
||||||
|
let install_name = source_rel
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())?;
|
||||||
|
|
||||||
|
// 1. 直接相对路径命中(明确路径优先)——必须校验 SKILL.md,否则同名空壳目录
|
||||||
|
// (如 ast-grep/agent-skill 根下的 plugin 包目录 ast-grep/)会被误判为源目录。
|
||||||
let direct = root.join(&source_rel);
|
let direct = root.join(&source_rel);
|
||||||
if direct.is_dir() {
|
if direct.is_dir() && direct.join("SKILL.md").is_file() {
|
||||||
return Some(direct);
|
return Some(direct);
|
||||||
}
|
}
|
||||||
|
|
||||||
let target_name = source_rel.file_name()?.to_string_lossy().to_string();
|
// 2. 按名字递归查找(find_skill_dir_by_name 已校验 SKILL.md)
|
||||||
if let Some(found) = Self::find_skill_dir_by_name(root, &target_name) {
|
if let Some(found) = Self::find_skill_dir_by_name(root, &install_name) {
|
||||||
log::info!(
|
log::info!(
|
||||||
"Skill directory '{}' not found at direct path, using fallback: {}",
|
"Skill directory '{}' not found at direct path, using fallback: {}",
|
||||||
target_name,
|
install_name,
|
||||||
found.display()
|
found.display()
|
||||||
);
|
);
|
||||||
return Some(found);
|
return Some(found);
|
||||||
}
|
}
|
||||||
|
|
||||||
if root.is_dir() && root.join("SKILL.md").exists() {
|
// 3. 兜底:仓库根本身是 skill
|
||||||
|
if root.join("SKILL.md").is_file() {
|
||||||
log::info!(
|
log::info!(
|
||||||
"Skill directory '{}' not found, but SKILL.md exists at root, using repo root",
|
"Skill directory '{}' not found, but SKILL.md exists at root, using repo root",
|
||||||
target_name,
|
install_name,
|
||||||
);
|
);
|
||||||
return Some(root.to_path_buf());
|
return Some(root.to_path_buf());
|
||||||
}
|
}
|
||||||
@@ -4452,4 +4459,74 @@ mod tests {
|
|||||||
"existing destination skill should be preserved"
|
"existing destination skill should be preserved"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_skill_source_dir_rejects_same_name_wrapper_without_skill_md() {
|
||||||
|
// 复刻 issue #4141:ast-grep/agent-skill 结构。仓库根下有同名目录 ast-grep/
|
||||||
|
// (plugin 包,无 SKILL.md),真正的 skill 在 ast-grep/skills/ast-grep/SKILL.md。
|
||||||
|
let temp = tempdir().expect("tempdir");
|
||||||
|
let wrapper = temp.path().join("ast-grep");
|
||||||
|
fs::create_dir_all(wrapper.join(".claude-plugin")).expect("create wrapper plugin dir");
|
||||||
|
fs::write(
|
||||||
|
wrapper.join(".claude-plugin").join("plugin.json"),
|
||||||
|
"{\"name\":\"ast-grep\"}",
|
||||||
|
)
|
||||||
|
.expect("write plugin.json");
|
||||||
|
let real_skill = wrapper.join("skills").join("ast-grep");
|
||||||
|
write_skill(&real_skill, "ast-grep");
|
||||||
|
|
||||||
|
// directory 只给了 skill 名 "ast-grep"(skills.sh API 的语义),不能命中空壳 wrapper。
|
||||||
|
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "ast-grep")
|
||||||
|
.expect("should resolve to the inner skill dir, not the same-name wrapper");
|
||||||
|
|
||||||
|
assert_eq!(resolved, real_skill);
|
||||||
|
assert!(resolved.join("SKILL.md").is_file());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_skill_source_dir_finds_two_level_catalog_skill() {
|
||||||
|
// catalog layout:skills/category/foo/SKILL.md(depth 3,find_skill_dir_by_name 可达)。
|
||||||
|
let temp = tempdir().expect("tempdir");
|
||||||
|
let catalog_skill = temp.path().join("skills").join("category").join("foo");
|
||||||
|
write_skill(&catalog_skill, "Foo Skill");
|
||||||
|
|
||||||
|
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "foo")
|
||||||
|
.expect("should resolve the two-level catalog skill by name");
|
||||||
|
|
||||||
|
assert_eq!(resolved, catalog_skill);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_skill_source_dir_returns_none_for_wrapper_without_inner_skill() {
|
||||||
|
// 同名 wrapper 存在、无 SKILL.md,且无 inner skill / root SKILL.md 可兜底时,
|
||||||
|
// 必须返回 None——守住 #4141 这个 bug class 的负例(不能把空壳目录当源目录)。
|
||||||
|
let temp = tempdir().expect("tempdir");
|
||||||
|
let wrapper = temp.path().join("ast-grep");
|
||||||
|
fs::create_dir_all(wrapper.join(".claude-plugin")).expect("create wrapper plugin dir");
|
||||||
|
fs::write(
|
||||||
|
wrapper.join(".claude-plugin").join("plugin.json"),
|
||||||
|
"{\"name\":\"ast-grep\"}",
|
||||||
|
)
|
||||||
|
.expect("write plugin.json");
|
||||||
|
|
||||||
|
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "ast-grep");
|
||||||
|
assert!(
|
||||||
|
resolved.is_none(),
|
||||||
|
"wrapper dir without SKILL.md and no inner skill must resolve to None, got {:?}",
|
||||||
|
resolved
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_skill_source_dir_returns_none_when_no_skill_md_anywhere() {
|
||||||
|
let temp = tempdir().expect("tempdir");
|
||||||
|
fs::create_dir_all(temp.path().join("skills").join("foo")).expect("create empty skill dir");
|
||||||
|
fs::write(temp.path().join("README.md"), "no skills here").expect("write README");
|
||||||
|
|
||||||
|
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "foo");
|
||||||
|
assert!(
|
||||||
|
resolved.is_none(),
|
||||||
|
"no SKILL.md anywhere must resolve to None"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useCodexOauthQuotaByAccountId } from "@/lib/query/subscription";
|
||||||
|
import { SubscriptionQuotaView } from "@/components/SubscriptionQuotaFooter";
|
||||||
|
|
||||||
|
interface CodexOauthAccountQuotaProps {
|
||||||
|
/** cc-switch 自管的 ChatGPT 账号 ID */
|
||||||
|
accountId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 → 认证中心里,单个 ChatGPT (Codex OAuth) 账号的用量展示。
|
||||||
|
*
|
||||||
|
* 直接按 accountId 查询 cc-switch 自管 OAuth token 的订阅额度,复用
|
||||||
|
* `SubscriptionQuotaView` 的展开布局(进度条 + 重置倒计时 + 刷新按钮),
|
||||||
|
* 因此与供应商卡片里的额度展示保持完全一致的观感与状态处理。
|
||||||
|
*
|
||||||
|
* 面板打开时拉取一次,不轮询;用户可点卡片内的刷新按钮手动更新。
|
||||||
|
*/
|
||||||
|
const CodexOauthAccountQuota: React.FC<CodexOauthAccountQuotaProps> = ({
|
||||||
|
accountId,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
data: quota,
|
||||||
|
isFetching: loading,
|
||||||
|
refetch,
|
||||||
|
} = useCodexOauthQuotaByAccountId(accountId, {
|
||||||
|
enabled: true,
|
||||||
|
autoQuery: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 首次加载占位:账号头部由父组件独立渲染,这里只负责用量区。
|
||||||
|
// 用量请求是异步的(Tauri invoke + React Query),加载期间给一个
|
||||||
|
// 与最终额度卡片同形状(rounded-xl / border / bg-card)的转圈占位,
|
||||||
|
// 这样账号会立刻显示、用量数据到达后原地平滑替换,不产生跳版。
|
||||||
|
if (loading && !quota) {
|
||||||
|
return (
|
||||||
|
<div className="mt-3 flex items-center justify-center rounded-xl border border-border-default bg-card py-5 shadow-sm">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SubscriptionQuotaView
|
||||||
|
quota={quota}
|
||||||
|
loading={loading}
|
||||||
|
refetch={refetch}
|
||||||
|
appIdForExpiredHint="codex_oauth"
|
||||||
|
inline={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CodexOauthAccountQuota;
|
||||||
@@ -32,7 +32,7 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
|
|||||||
grokbuild: "AGENTS.md",
|
grokbuild: "AGENTS.md",
|
||||||
opencode: "AGENTS.md",
|
opencode: "AGENTS.md",
|
||||||
openclaw: "AGENTS.md",
|
openclaw: "AGENTS.md",
|
||||||
hermes: "AGENTS.md",
|
hermes: "SOUL.md",
|
||||||
};
|
};
|
||||||
const filename = filenameMap[appId];
|
const filename = filenameMap[appId];
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
|||||||
@@ -24,9 +24,12 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCodexOauth } from "./hooks/useCodexOauth";
|
import { useCodexOauth } from "./hooks/useCodexOauth";
|
||||||
import { copyText } from "@/lib/clipboard";
|
import { copyText } from "@/lib/clipboard";
|
||||||
|
import CodexOauthAccountQuota from "@/components/CodexOauthAccountQuota";
|
||||||
|
|
||||||
interface CodexOAuthSectionProps {
|
interface CodexOAuthSectionProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** 是否展示每个账号的订阅额度 */
|
||||||
|
showAccountQuota?: boolean;
|
||||||
/** 当前选中的 ChatGPT 账号 ID */
|
/** 当前选中的 ChatGPT 账号 ID */
|
||||||
selectedAccountId?: string | null;
|
selectedAccountId?: string | null;
|
||||||
/** 账号选择回调 */
|
/** 账号选择回调 */
|
||||||
@@ -45,6 +48,7 @@ interface CodexOAuthSectionProps {
|
|||||||
*/
|
*/
|
||||||
export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
||||||
className,
|
className,
|
||||||
|
showAccountQuota = false,
|
||||||
selectedAccountId,
|
selectedAccountId,
|
||||||
onAccountSelect,
|
onAccountSelect,
|
||||||
fastModeEnabled = false,
|
fastModeEnabled = false,
|
||||||
@@ -178,47 +182,52 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
|||||||
{accounts.map((account) => (
|
{accounts.map((account) => (
|
||||||
<div
|
<div
|
||||||
key={account.id}
|
key={account.id}
|
||||||
className="flex items-center justify-between p-2 rounded-md border bg-muted/30"
|
className="space-y-2 p-2 rounded-md border bg-muted/30"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between">
|
||||||
<User className="h-5 w-5 text-muted-foreground" />
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium">{account.login}</span>
|
<User className="h-5 w-5 text-muted-foreground" />
|
||||||
{defaultAccountId === account.id && (
|
<span className="text-sm font-medium">{account.login}</span>
|
||||||
<Badge variant="secondary" className="text-xs">
|
{defaultAccountId === account.id && (
|
||||||
{t("codexOauth.defaultAccount", "默认")}
|
<Badge variant="secondary" className="text-xs">
|
||||||
</Badge>
|
{t("codexOauth.defaultAccount", "默认")}
|
||||||
)}
|
</Badge>
|
||||||
{selectedAccountId === account.id && (
|
)}
|
||||||
<Badge variant="outline" className="text-xs">
|
{selectedAccountId === account.id && (
|
||||||
{t("codexOauth.selected", "已选中")}
|
<Badge variant="outline" className="text-xs">
|
||||||
</Badge>
|
{t("codexOauth.selected", "已选中")}
|
||||||
)}
|
</Badge>
|
||||||
</div>
|
)}
|
||||||
<div className="flex items-center gap-1">
|
</div>
|
||||||
{defaultAccountId !== account.id && (
|
<div className="flex items-center gap-1">
|
||||||
|
{defaultAccountId !== account.id && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs text-muted-foreground"
|
||||||
|
onClick={() => setDefaultAccount(account.id)}
|
||||||
|
disabled={isSettingDefaultAccount}
|
||||||
|
>
|
||||||
|
{t("codexOauth.setAsDefault", "设为默认")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
className="h-7 px-2 text-xs text-muted-foreground"
|
className="h-7 w-7 text-muted-foreground hover:text-red-500"
|
||||||
onClick={() => setDefaultAccount(account.id)}
|
onClick={(e) => handleRemoveAccount(account.id, e)}
|
||||||
disabled={isSettingDefaultAccount}
|
disabled={isRemovingAccount}
|
||||||
|
title={t("codexOauth.removeAccount", "移除账号")}
|
||||||
>
|
>
|
||||||
{t("codexOauth.setAsDefault", "设为默认")}
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-red-500"
|
|
||||||
onClick={(e) => handleRemoveAccount(account.id, e)}
|
|
||||||
disabled={isRemovingAccount}
|
|
||||||
title={t("codexOauth.removeAccount", "移除账号")}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
{showAccountQuota && (
|
||||||
|
<CodexOauthAccountQuota accountId={account.id} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function AuthCenterPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CodexOAuthSection />
|
<CodexOAuthSection showAccountQuota />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl border border-border/60 bg-card/60 p-6">
|
<section className="rounded-xl border border-border/60 bg-card/60 p-6">
|
||||||
|
|||||||
@@ -112,20 +112,18 @@ export interface UseCodexOauthQuotaOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Codex OAuth (ChatGPT Plus/Pro 反代) 订阅额度查询 hook
|
* Codex OAuth 订阅额度查询 hook(按账号 ID)
|
||||||
*
|
*
|
||||||
* 与 `useSubscriptionQuota` 平行:数据走 cc-switch 自管的 OAuth token,
|
* 直接以 cc-switch 自管的 ChatGPT 账号 ID 查询额度,供认证中心里逐个账号
|
||||||
* 而不是 Codex CLI 的 ~/.codex/auth.json。
|
* 展示用量时复用。Query key 与 `useCodexOauthQuota` 一致,绑定到同一账号的
|
||||||
*
|
* 供应商卡片与账号列表会自动去重共享同一份请求缓存。
|
||||||
* Query key 包含 accountId,多张卡片绑定到同一账号时会自动去重共享请求。
|
|
||||||
* accountId 为 null 时使用 "default" 占位,让后端 fallback 到默认账号。
|
* accountId 为 null 时使用 "default" 占位,让后端 fallback 到默认账号。
|
||||||
*/
|
*/
|
||||||
export function useCodexOauthQuota(
|
export function useCodexOauthQuotaByAccountId(
|
||||||
meta: ProviderMeta | undefined,
|
accountId: string | null,
|
||||||
options: UseCodexOauthQuotaOptions = {},
|
options: UseCodexOauthQuotaOptions = {},
|
||||||
) {
|
) {
|
||||||
const { enabled = true, autoQuery = false } = options;
|
const { enabled = true, autoQuery = false } = options;
|
||||||
const accountId = resolveManagedAccountId(meta, PROVIDER_TYPES.CODEX_OAUTH);
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["codex_oauth", "quota", accountId ?? "default"],
|
queryKey: ["codex_oauth", "quota", accountId ?? "default"],
|
||||||
queryFn: () => subscriptionApi.getCodexOauthQuota(accountId),
|
queryFn: () => subscriptionApi.getCodexOauthQuota(accountId),
|
||||||
@@ -140,6 +138,21 @@ export function useCodexOauthQuota(
|
|||||||
return useQuotaKeepLastGood(query, accountId ?? "default");
|
return useQuotaKeepLastGood(query, accountId ?? "default");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Codex OAuth (ChatGPT Plus/Pro 反代) 订阅额度查询 hook
|
||||||
|
*
|
||||||
|
* 与 `useSubscriptionQuota` 平行:数据走 cc-switch 自管的 OAuth token,
|
||||||
|
* 而不是 Codex CLI 的 ~/.codex/auth.json。账号 ID 从供应商 meta 的
|
||||||
|
* authBinding 中解析,再委托给 `useCodexOauthQuotaByAccountId`。
|
||||||
|
*/
|
||||||
|
export function useCodexOauthQuota(
|
||||||
|
meta: ProviderMeta | undefined,
|
||||||
|
options: UseCodexOauthQuotaOptions = {},
|
||||||
|
) {
|
||||||
|
const accountId = resolveManagedAccountId(meta, PROVIDER_TYPES.CODEX_OAUTH);
|
||||||
|
return useCodexOauthQuotaByAccountId(accountId, options);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* xAI OAuth (SuperGrok 反代) 订阅额度查询 hook
|
* xAI OAuth (SuperGrok 反代) 订阅额度查询 hook
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { CodexOAuthSection } from "@/components/providers/forms/CodexOAuthSection";
|
||||||
|
import { AuthCenterPanel } from "@/components/settings/AuthCenterPanel";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
useCodexOauth: vi.fn(),
|
||||||
|
renderAccountQuota: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/providers/forms/hooks/useCodexOauth", () => ({
|
||||||
|
useCodexOauth: mocks.useCodexOauth,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/CodexOauthAccountQuota", () => ({
|
||||||
|
default: ({ accountId }: { accountId: string }) => {
|
||||||
|
mocks.renderAccountQuota(accountId);
|
||||||
|
return <div data-testid="account-quota">{accountId}</div>;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/providers/forms/CopilotAuthSection", () => ({
|
||||||
|
CopilotAuthSection: () => <div />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/providers/forms/XaiOAuthSection", () => ({
|
||||||
|
XaiOAuthSection: () => <div />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("CodexOAuthSection", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.useCodexOauth.mockReturnValue({
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
id: "account-1",
|
||||||
|
provider: "codex_oauth",
|
||||||
|
login: "user@example.com",
|
||||||
|
avatar_url: null,
|
||||||
|
authenticated_at: 0,
|
||||||
|
is_default: true,
|
||||||
|
github_domain: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaultAccountId: "account-1",
|
||||||
|
hasAnyAccount: true,
|
||||||
|
pollingState: "idle",
|
||||||
|
deviceCode: null,
|
||||||
|
error: null,
|
||||||
|
isPolling: false,
|
||||||
|
isAddingAccount: false,
|
||||||
|
isRemovingAccount: false,
|
||||||
|
isSettingDefaultAccount: false,
|
||||||
|
addAccount: vi.fn(),
|
||||||
|
removeAccount: vi.fn(),
|
||||||
|
setDefaultAccount: vi.fn(),
|
||||||
|
cancelAuth: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render account quota by default", () => {
|
||||||
|
render(<CodexOAuthSection />);
|
||||||
|
|
||||||
|
expect(mocks.renderAccountQuota).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByTestId("account-quota")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders account quota in Auth Center", () => {
|
||||||
|
render(<AuthCenterPanel />);
|
||||||
|
|
||||||
|
expect(mocks.renderAccountQuota).toHaveBeenCalledWith("account-1");
|
||||||
|
expect(screen.getByTestId("account-quota")).toHaveTextContent("account-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user