mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 01:34:51 +08:00
fix(codex): explain why the official model list is unavailable
When the Codex Stack page fell back to the bundled list or had no list at all, every cause collapsed into one generic notice: the login check, the official fetch and the bundled CLI run all returned a bare None, and skipped logins were not even logged. Carry the reason instead: - Official list: no usable ChatGPT login, login not refreshed for over 8 days, login missing the workspace or user ID, no Codex version, fetch failed, or an invalid list. - Bundled list: no Codex CLI found, the CLI failed to print a list, or the list has no usable model. A missing CLI is now told apart from a failing one, and the failure detail reaches the default log level. The Stack view exposes the codes as `noticeReasons`, and the notice lists them in all four locales. Each fallback also logs one warning. Recover automatically once the official list is usable again. The background check only rewrote the client when the cached list changed, so after a fallback (for example an expired login that Codex later refreshed) a 304 or an unchanged list left the notice in place until the next manual switch. A refresh after a fallback now always rewrites the client, and while the cached entry is still fresh the check rewrites from it without fetching. Refs #8087, #7993
This commit is contained in:
1 parent
979ce6a60b
commit
2e20cdcb5c
12 files changed
+596
-94
No files matched your search
@@ -1811,10 +1811,29 @@ fn codex_bundled_models_command(candidate: &Path) -> Command {
|
||||
command
|
||||
}
|
||||
|
||||
/// 没跑出 Codex 自带的模型列表。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum CodexBundledModelsError {
|
||||
/// 每个候选都启动不了:本机找不到 Codex 命令行。
|
||||
NoCli,
|
||||
/// 找到了命令行,但没跑出能用的列表(退出码非零、输出不是 JSON、列表为空)。带最后
|
||||
/// 一个候选的情况,写日志用。
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CodexBundledModelsError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NoCli => f.write_str("本机找不到 Codex 命令行"),
|
||||
Self::Failed(detail) => f.write_str(detail),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 本机 Codex 自带的完整模型列表(`codex debug models --bundled`)。和账号无关,版本和
|
||||
/// 那个二进制一致。不能用不带 `--bundled` 的版本:那会读到 CC Switch 自己写的目录。
|
||||
pub(crate) fn load_codex_bundled_models() -> Option<Vec<Value>> {
|
||||
first_bundled_catalog(|catalog| {
|
||||
pub(crate) fn load_codex_bundled_models() -> Result<Vec<Value>, CodexBundledModelsError> {
|
||||
first_bundled_catalog(codex_cli_candidates(), |catalog| {
|
||||
catalog
|
||||
.get("models")
|
||||
.and_then(Value::as_array)
|
||||
@@ -1824,13 +1843,21 @@ pub(crate) fn load_codex_bundled_models() -> Option<Vec<Value>> {
|
||||
}
|
||||
|
||||
/// 依次跑各个候选的 `codex debug models --bundled`,返回第一份 `pick` 取得出东西的结果。
|
||||
fn first_bundled_catalog<T>(pick: impl Fn(&Value) -> Option<T>) -> Option<T> {
|
||||
for candidate in codex_cli_candidates() {
|
||||
/// 候选里有不存在的路径(固定位置、PATH 上的裸名),启动不了不算失败;一个都启动不了才是
|
||||
/// 找不到命令行。
|
||||
fn first_bundled_catalog<T>(
|
||||
candidates: Vec<PathBuf>,
|
||||
pick: impl Fn(&Value) -> Option<T>,
|
||||
) -> Result<T, CodexBundledModelsError> {
|
||||
let mut failure = None;
|
||||
for candidate in candidates {
|
||||
let candidate_label = candidate.to_string_lossy();
|
||||
let output = match codex_bundled_models_command(&candidate).output() {
|
||||
Ok(output) => output,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(err) => {
|
||||
log::debug!("failed to run `{candidate_label} debug models --bundled`: {err}");
|
||||
failure = Some(format!("`{candidate_label}` 启动失败: {err}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -1838,6 +1865,11 @@ fn first_bundled_catalog<T>(pick: impl Fn(&Value) -> Option<T>) -> Option<T> {
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
log::debug!("`{candidate_label} debug models --bundled` failed: {stderr}");
|
||||
failure = Some(format!(
|
||||
"`{candidate_label} debug models --bundled` 失败({}): {}",
|
||||
output.status,
|
||||
stderr.trim()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1847,15 +1879,24 @@ fn first_bundled_catalog<T>(pick: impl Fn(&Value) -> Option<T>) -> Option<T> {
|
||||
log::debug!(
|
||||
"Failed to parse `{candidate_label} debug models --bundled` output: {e}"
|
||||
);
|
||||
failure = Some(format!(
|
||||
"`{candidate_label} debug models --bundled` 的输出不是 JSON: {e}"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(found) = pick(&catalog) {
|
||||
return Some(found);
|
||||
return Ok(found);
|
||||
}
|
||||
failure = Some(format!(
|
||||
"`{candidate_label} debug models --bundled` 的输出里没有模型"
|
||||
));
|
||||
}
|
||||
|
||||
None
|
||||
Err(failure.map_or(
|
||||
CodexBundledModelsError::NoCli,
|
||||
CodexBundledModelsError::Failed,
|
||||
))
|
||||
}
|
||||
|
||||
/// 官方原生行:逐行补 Codex 解析器必需的字段(不覆盖已有值)、补旧的指令字段,再校验。
|
||||
@@ -2165,6 +2206,7 @@ fn codex_openai_official_models() -> Vec<Value> {
|
||||
CODEX_OPENAI_OFFICIAL_MODELS_CACHE
|
||||
.get_or_try_init(|| {
|
||||
load_codex_bundled_models()
|
||||
.ok()
|
||||
.and_then(normalize_codex_native_rows)
|
||||
.ok_or(())
|
||||
})
|
||||
@@ -5514,6 +5556,42 @@ wire_api = "responses"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_models_tell_a_missing_cli_apart_from_a_failing_one() {
|
||||
let dir = tempfile::tempdir().expect("create temp dir");
|
||||
let missing = dir.path().join("missing-codex");
|
||||
let pick = |catalog: &Value| catalog.get("models").cloned();
|
||||
assert_eq!(
|
||||
first_bundled_catalog(vec![missing.clone()], pick).err(),
|
||||
Some(CodexBundledModelsError::NoCli)
|
||||
);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let script = |name: &str, body: &str| {
|
||||
let path = dir.path().join(name);
|
||||
fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("write script");
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o755))
|
||||
.expect("chmod script");
|
||||
path
|
||||
};
|
||||
let failing = script("failing-codex", "echo boom >&2; exit 1");
|
||||
let working = script("working-codex", r#"echo '{"models":[{"slug":"x"}]}'"#);
|
||||
|
||||
// 找到了命令行但跑失败:不能报成找不到。
|
||||
assert!(matches!(
|
||||
first_bundled_catalog(vec![missing.clone(), failing.clone()], pick),
|
||||
Err(CodexBundledModelsError::Failed(detail)) if detail.contains("boom")
|
||||
));
|
||||
// 前面的失败不影响后面能用的候选。
|
||||
assert_eq!(
|
||||
first_bundled_catalog(vec![missing, failing, working], pick).ok(),
|
||||
Some(json!([{ "slug": "x" }]))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_bundled_models_command_uses_expected_program_and_args() {
|
||||
let command = codex_bundled_models_command(Path::new("codex"));
|
||||
|
||||
@@ -1201,14 +1201,18 @@ pub fn stack_views(state: &AppState, app: &AppType) -> Result<StackView, String>
|
||||
let mode = current::mode_state(app);
|
||||
let route = mode.proxy_route.as_deref().filter(|_| mode.is_proxy());
|
||||
let members = stack::members(&state.db, app, &stack, route).map_err(err)?;
|
||||
let notice = match app {
|
||||
AppType::Codex => codex_stack_notice(state, &stack),
|
||||
_ => None,
|
||||
let (notice, notice_reasons) = match app {
|
||||
AppType::Codex => codex_stack_notice_with_reasons(state, &stack)
|
||||
.map_or((None, Vec::new()), |(notice, reasons)| {
|
||||
(Some(notice), reasons)
|
||||
}),
|
||||
_ => (None, Vec::new()),
|
||||
};
|
||||
Ok(StackView {
|
||||
active: mode.is_proxy() && stack.enabled,
|
||||
members: stack::member_views(&members),
|
||||
notice,
|
||||
notice_reasons,
|
||||
stale_clients: None,
|
||||
stale_revision: None,
|
||||
})
|
||||
@@ -1239,6 +1243,15 @@ pub async fn stack_view_with_clients(state: &AppState, app: &AppType) -> Result<
|
||||
/// 目录文件(Stack 模型不发布);或者官方做默认、最近一次写目录时没拿到官方列表,或者拿到的
|
||||
/// 列表里没有能选的模型(本机 Codex 太旧)。
|
||||
fn codex_stack_notice(state: &AppState, stack: &StackState) -> Option<&'static str> {
|
||||
codex_stack_notice_with_reasons(state, stack).map(|(notice, _)| notice)
|
||||
}
|
||||
|
||||
/// [`codex_stack_notice`] 再加上没取到列表的原因码(见 [`StackView::notice_reasons`])。
|
||||
fn codex_stack_notice_with_reasons(
|
||||
state: &AppState,
|
||||
stack: &StackState,
|
||||
) -> Option<(&'static str, Vec<&'static str>)> {
|
||||
use codex_official_models::NativeSource;
|
||||
let (_, route) = attached_route(state, &AppType::Codex).ok()??;
|
||||
let published =
|
||||
stack::published_members(&state.db, &AppType::Codex, stack, Some(&route.id)).ok()?;
|
||||
@@ -1246,16 +1259,19 @@ fn codex_stack_notice(state: &AppState, stack: &StackState) -> Option<&'static s
|
||||
return None;
|
||||
}
|
||||
if codex_direct::route_owns_catalog(&route) {
|
||||
return Some("routeOwnsCatalog");
|
||||
return Some(("routeOwnsCatalog", Vec::new()));
|
||||
}
|
||||
if !codex_direct::is_official(&route) {
|
||||
return None;
|
||||
}
|
||||
match codex_official_models::last_source()? {
|
||||
codex_official_models::NativeSource::Fetched => None,
|
||||
codex_official_models::NativeSource::Bundled => Some("officialModelsBundled"),
|
||||
codex_official_models::NativeSource::Unavailable => Some("officialModelsUnavailable"),
|
||||
codex_official_models::NativeSource::Outdated => Some("officialModelsOutdated"),
|
||||
NativeSource::Fetched => None,
|
||||
NativeSource::Bundled(official) => Some(("officialModelsBundled", vec![official.code()])),
|
||||
NativeSource::Unavailable { official, bundled } => Some((
|
||||
"officialModelsUnavailable",
|
||||
vec![official.code(), bundled.code()],
|
||||
)),
|
||||
NativeSource::Outdated => Some(("officialModelsOutdated", Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1362,6 +1378,7 @@ async fn refresh_codex_official_models(state: &AppState) {
|
||||
}
|
||||
};
|
||||
let Some(version) = codex_official_models::needs_refresh(&login) else {
|
||||
codex_official_models::catch_up_after_fallback(&login);
|
||||
return;
|
||||
};
|
||||
if let Err(error) = codex_official_models::refresh(&login, &version) {
|
||||
@@ -1404,7 +1421,9 @@ fn codex_official_login_now(
|
||||
stack: &members,
|
||||
};
|
||||
let prepared = codex_direct::prepare(&state.codex_oauth_manager, &owner, &spec).map_err(err)?;
|
||||
codex_direct::predicted_official_login(&state.db, &owner, &spec, &prepared).map_err(err)
|
||||
codex_direct::predicted_official_login(&state.db, &owner, &spec, &prepared)
|
||||
.map(Result::ok)
|
||||
.map_err(err)
|
||||
}
|
||||
|
||||
/// 代理换了地址之后,按新地址重写每个接上代理的应用。一个应用失败(比如配置文件解析
|
||||
@@ -6720,6 +6739,41 @@ model_provider = "c"
|
||||
rows
|
||||
}
|
||||
|
||||
/// 写目录那一刻钥匙串没读出来,聚合的模型暂不可用;钥匙串又读得出时缓存里的条目还新,
|
||||
/// 后台检查不联网、直接按它重写(不等条目过了新鲜期)。
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn codex_a_fallback_recovers_from_a_fresh_entry_once_the_login_is_back() {
|
||||
let _home = Home::new();
|
||||
seed_codex(
|
||||
"cli_auth_credentials_store = \"auto\"\n",
|
||||
Some(&chatgpt("ws", "alice")),
|
||||
);
|
||||
let keychain = Arc::new(std::sync::Mutex::new(CodexKeychainLogin::Missing));
|
||||
let fake = fake_models_sharing(vec![official_list(&[])], keychain.clone(), None);
|
||||
let official = crate::database::CODEX_OFFICIAL_PROVIDER_ID;
|
||||
let state = state_with(AppType::Codex, &codex_official_stack_rows(), official).await;
|
||||
enter(&state, &AppType::Codex, true).await.expect("enter");
|
||||
set_codex_member(&state, "deepseek", true).await;
|
||||
assert!(catalog_slugs().contains(&"gpt-6-sol".to_string()));
|
||||
|
||||
*keychain.lock().unwrap() = CodexKeychainLogin::Unknown;
|
||||
resync_route(&state, &AppType::Codex).await.expect("resync");
|
||||
let view = stack_views(&state, &AppType::Codex).unwrap();
|
||||
assert_eq!(view.notice, Some("officialModelsUnavailable"));
|
||||
assert_eq!(view.notice_reasons, vec!["noLogin", "noCli"]);
|
||||
|
||||
*keychain.lock().unwrap() = CodexKeychainLogin::Missing;
|
||||
*fake.now.lock().unwrap() = NOW + 3600;
|
||||
check_codex_official_models(&state).await;
|
||||
assert_eq!(fake.calls.lock().unwrap().len(), 1, "no new fetch");
|
||||
assert!(catalog_slugs().contains(&"gpt-6-sol".to_string()));
|
||||
assert!(stack_views(&state, &AppType::Codex)
|
||||
.unwrap()
|
||||
.notice
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn codex_official_route_lists_every_official_model_before_the_stacked_ones() {
|
||||
@@ -6958,10 +7012,9 @@ model_provider = "c"
|
||||
enter(&state, &AppType::Codex, true).await.expect("enter");
|
||||
set_codex_member(&state, "deepseek", true).await;
|
||||
assert!(fake.calls.lock().unwrap().is_empty());
|
||||
assert_eq!(
|
||||
stack_views(&state, &AppType::Codex).unwrap().notice,
|
||||
Some("officialModelsUnavailable")
|
||||
);
|
||||
let view = stack_views(&state, &AppType::Codex).unwrap();
|
||||
assert_eq!(view.notice, Some("officialModelsUnavailable"));
|
||||
assert_eq!(view.notice_reasons, vec!["noLogin", "noCli"]);
|
||||
exit(&state, &AppType::Codex).await.expect("exit");
|
||||
set_codex_member(&state, "deepseek", false).await;
|
||||
drop(fake);
|
||||
@@ -6993,6 +7046,21 @@ model_provider = "c"
|
||||
stack_views(&state, &AppType::Codex).unwrap().notice,
|
||||
Some("officialModelsUnavailable")
|
||||
);
|
||||
exit(&state, &AppType::Codex).await.expect("exit");
|
||||
set_codex_member(&state, "deepseek", false).await;
|
||||
drop(fake);
|
||||
|
||||
// 登录超过 8 天没刷新:不拉取,提示里说清是登录过期,而不是笼统的「读取不到」。
|
||||
let mut stale = chatgpt("ws", "alice");
|
||||
stale["last_refresh"] = serde_json::json!("2026-01-01T00:00:00Z");
|
||||
seed_codex("", Some(&stale));
|
||||
let fake = fake_models(vec![official_list(&[])], CodexKeychainLogin::Missing, None);
|
||||
enter(&state, &AppType::Codex, true).await.expect("enter");
|
||||
set_codex_member(&state, "deepseek", true).await;
|
||||
assert!(fake.calls.lock().unwrap().is_empty());
|
||||
let view = stack_views(&state, &AppType::Codex).unwrap();
|
||||
assert_eq!(view.notice, Some("officialModelsUnavailable"));
|
||||
assert_eq!(view.notice_reasons, vec!["loginStale", "noCli"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -621,6 +621,12 @@ pub struct StackView {
|
||||
/// 暂不可用;`officialModelsOutdated` 本机 Codex 太旧,拉到的官方列表里没有能选的模型。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub notice: Option<&'static str>,
|
||||
/// `notice` 是 `officialModelsBundled` / `officialModelsUnavailable` 时,没取到列表的原因
|
||||
/// 码:先是官方列表的(`noLogin`、`loginStale`、`loginIncomplete`、`noVersion`、
|
||||
/// `fetchFailed`、`invalidList`),不可用时再跟自带列表的(`noCli`、`cliFailed`、
|
||||
/// `nothingUsable`)。
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub notice_reasons: Vec<&'static str>,
|
||||
/// Codex 客户端还在用旧的模型列表(启动时读的目录),Stack 模型看不到,要重启才行。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stale_clients: Option<StaleClients>,
|
||||
|
||||
@@ -50,7 +50,7 @@ use crate::services::subscription::CodexKeychainLogin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::codex_login::{self, AuthInput, AuthPlan, AuthTarget, LoginStash, STASH_FILENAME};
|
||||
use super::codex_official_models::{self, NativeRows, OfficialLogin};
|
||||
use super::codex_official_models::{self, NativeRows, OfficialLogin, OfficialSkip};
|
||||
use super::ProviderService;
|
||||
|
||||
fn app() -> &'static str {
|
||||
@@ -229,7 +229,7 @@ pub(crate) async fn prepare_official_rows(
|
||||
prepared.keychain = Some(off_runtime(codex_official_models::keychain_login).await?);
|
||||
}
|
||||
let login = predicted_official_login(db, owner, target, prepared)?;
|
||||
let rows = off_runtime(move || codex_official_models::rows_for_switch(login.as_ref())).await?;
|
||||
let rows = off_runtime(move || codex_official_models::rows_for_switch(&login)).await?;
|
||||
// 取官方行最多要 10 秒,这期间 Codex 可能在钥匙串里换了号。取完再读一次,拿锁后按
|
||||
// 这次读到的核对(见 `run_with_edits`),换了号就停下。
|
||||
if rows.identity().is_some() && prepared.keychain.is_some() {
|
||||
@@ -253,14 +253,15 @@ pub(crate) fn needs_official_rows(route: &Provider, stack: &[Member]) -> bool {
|
||||
!stack.is_empty() && is_official(route)
|
||||
}
|
||||
|
||||
/// 按未加锁读到的内容预测这次操作之后 Codex 会用的登录(能取官方列表的才返回)。钥匙串
|
||||
/// 没预先读过([`prepare_official_rows`] 之外的调用方)就在用到时读,调用方要在阻塞线程里。
|
||||
/// 按未加锁读到的内容预测这次操作之后 Codex 会用的登录(能取官方列表的才是 `Ok`,否则带
|
||||
/// 着取不到的原因)。钥匙串没预先读过([`prepare_official_rows`] 之外的调用方)就在用到时
|
||||
/// 读,调用方要在阻塞线程里。
|
||||
pub(crate) fn predicted_official_login(
|
||||
db: &Database,
|
||||
owner: &Owner<'_>,
|
||||
target: &Target<'_>,
|
||||
prepared: &Prepared,
|
||||
) -> Result<Option<OfficialLogin>, AppError> {
|
||||
) -> Result<Result<OfficialLogin, OfficialSkip>, AppError> {
|
||||
// 先按没有官方行算一遍,拿到这次对 auth.json 的去向(目录在第二遍才算)。
|
||||
let planned = plan(db, owner, target, prepared)?;
|
||||
let live = read_current(&get_codex_auth_path())
|
||||
@@ -289,8 +290,8 @@ pub(crate) fn predicted_official_login(
|
||||
};
|
||||
Ok(
|
||||
login_after(&auth_plan, live.as_ref(), &read_config_text(), keychain)
|
||||
.as_ref()
|
||||
.and_then(OfficialLogin::of),
|
||||
.ok_or(OfficialSkip::NoLogin)
|
||||
.and_then(|auth| OfficialLogin::of(&auth)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -990,8 +991,7 @@ pub(crate) fn run_with_edits(
|
||||
.unwrap_or(CodexKeychainLogin::Unknown)
|
||||
};
|
||||
let actual = login_after(&auth_plan, live_auth.as_ref(), &config_text, keychain)
|
||||
.as_ref()
|
||||
.and_then(OfficialLogin::of)
|
||||
.and_then(|auth| OfficialLogin::of(&auth).ok())
|
||||
.map(|login| login.identity);
|
||||
if actual.as_deref() != Some(expected.as_str()) {
|
||||
return Err(AppError::localized(
|
||||
|
||||
@@ -40,7 +40,7 @@ use serde_json::Value;
|
||||
|
||||
use crate::codex_config::{
|
||||
extract_codex_auth_user_identity, get_codex_config_dir, load_codex_bundled_models,
|
||||
normalize_codex_native_rows, read_codex_config_text,
|
||||
normalize_codex_native_rows, read_codex_config_text, CodexBundledModelsError,
|
||||
};
|
||||
use crate::codex_state_db::{codex_state_db_is_lockable, codex_state_db_paths};
|
||||
use crate::database::Database;
|
||||
@@ -74,17 +74,22 @@ impl OfficialLogin {
|
||||
/// 从登录 JSON 取:要有有效的 access token、工作区 ID 和稳定的用户身份。取不到稳定
|
||||
/// 用户身份(没有 id_token,或里面没有 `sub`)时不拉取,和 Codex「身份不可用就不复用
|
||||
/// 缓存」同一取向。
|
||||
pub fn of(auth: &Value) -> Option<Self> {
|
||||
pub fn of(auth: &Value) -> Result<Self, OfficialSkip> {
|
||||
let (access_token, account_id, status, _) = parse_codex_credentials_json(&auth.to_string());
|
||||
if !matches!(status, CredentialStatus::Valid) {
|
||||
return None;
|
||||
match status {
|
||||
CredentialStatus::Valid => {}
|
||||
CredentialStatus::Expired => return Err(OfficialSkip::LoginStale),
|
||||
_ => return Err(OfficialSkip::NoLogin),
|
||||
}
|
||||
let access_token = access_token.filter(|token| !token.trim().is_empty())?;
|
||||
let access_token = access_token
|
||||
.filter(|token| !token.trim().is_empty())
|
||||
.ok_or(OfficialSkip::NoLogin)?;
|
||||
let account_id = account_id
|
||||
.map(|id| id.trim().to_string())
|
||||
.filter(|id| !id.is_empty())?;
|
||||
let user = extract_codex_auth_user_identity(auth)?;
|
||||
Some(Self {
|
||||
.filter(|id| !id.is_empty())
|
||||
.ok_or(OfficialSkip::LoginIncomplete)?;
|
||||
let user = extract_codex_auth_user_identity(auth).ok_or(OfficialSkip::LoginIncomplete)?;
|
||||
Ok(Self {
|
||||
identity: format!("{account_id}|{user}"),
|
||||
access_token,
|
||||
account_id,
|
||||
@@ -110,7 +115,7 @@ pub(crate) type FetchFn = dyn Fn(&OfficialLogin, &str, Option<&str>) -> Fetch +
|
||||
pub(crate) struct Env {
|
||||
pub codex_version: Box<dyn Fn() -> Option<String> + Send + Sync>,
|
||||
pub fetch: Box<FetchFn>,
|
||||
pub bundled: Box<dyn Fn() -> Option<Vec<Value>> + Send + Sync>,
|
||||
pub bundled: Box<dyn Fn() -> Result<Vec<Value>, BundledSkip> + Send + Sync>,
|
||||
pub keychain: Box<dyn Fn() -> CodexKeychainLogin + Send + Sync>,
|
||||
/// Unix 秒。
|
||||
pub now: Box<dyn Fn() -> u64 + Send + Sync>,
|
||||
@@ -126,7 +131,15 @@ impl Env {
|
||||
)
|
||||
}),
|
||||
fetch: Box::new(fetch_models),
|
||||
bundled: Box::new(load_codex_bundled_models),
|
||||
bundled: Box::new(|| {
|
||||
load_codex_bundled_models().map_err(|error| {
|
||||
log::warn!("读取 Codex 自带的模型列表失败: {error}");
|
||||
match error {
|
||||
CodexBundledModelsError::NoCli => BundledSkip::NoCli,
|
||||
CodexBundledModelsError::Failed(_) => BundledSkip::CliFailed,
|
||||
}
|
||||
})
|
||||
}),
|
||||
keychain: Box::new(crate::services::subscription::read_codex_keychain_login),
|
||||
now: Box::new(|| {
|
||||
SystemTime::now()
|
||||
@@ -163,6 +176,9 @@ pub(crate) fn reset_test_env() {
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
|
||||
CLIENT_BEHIND.store(false, Ordering::SeqCst);
|
||||
*last_source_slot()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
|
||||
}
|
||||
|
||||
/// 服务端按版本过滤列表:报低了少模型(`0.60.0` 返回 0 条),报高了会列出本机驱动不了的
|
||||
@@ -424,12 +440,92 @@ impl NativeRows {
|
||||
}
|
||||
}
|
||||
|
||||
/// 最近一次切换用的是哪种来源(界面提示用)。
|
||||
/// 没用上官方列表的原因(界面提示和日志用)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OfficialSkip {
|
||||
/// 操作之后 Codex 没有能用的 ChatGPT 登录(没登录、用 API Key 登录、钥匙串读不出、
|
||||
/// 登录只存在内存里)。
|
||||
NoLogin,
|
||||
/// 登录超过 8 天没刷新,access token 多半已过期,等 Codex 自己刷新。
|
||||
LoginStale,
|
||||
/// 登录里缺工作区 ID 或用户身份(`sub`)。
|
||||
LoginIncomplete,
|
||||
/// 拿不到本机 Codex 的版本(PATH 上没有 `codex`,也还没有线程记录)。
|
||||
NoVersion,
|
||||
/// 联网拉取失败(网络、HTTP 错误、被重定向)。
|
||||
FetchFailed,
|
||||
/// 拉到了,但列表不合格(缺必填字段或指令字段)。
|
||||
InvalidList,
|
||||
}
|
||||
|
||||
impl OfficialSkip {
|
||||
/// 给前端的原因码(`provider.officialModelsReason.<码>`)。
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::NoLogin => "noLogin",
|
||||
Self::LoginStale => "loginStale",
|
||||
Self::LoginIncomplete => "loginIncomplete",
|
||||
Self::NoVersion => "noVersion",
|
||||
Self::FetchFailed => "fetchFailed",
|
||||
Self::InvalidList => "invalidList",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for OfficialSkip {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::NoLogin => "没有能用的 ChatGPT 登录",
|
||||
Self::LoginStale => "登录超过 8 天没有刷新",
|
||||
Self::LoginIncomplete => "登录里缺工作区 ID 或用户身份",
|
||||
Self::NoVersion => "拿不到本机 Codex 的版本",
|
||||
Self::FetchFailed => "拉取失败",
|
||||
Self::InvalidList => "列表不合格",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 没用上 Codex 自带列表的原因。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BundledSkip {
|
||||
/// 本机找不到 Codex 命令行。
|
||||
NoCli,
|
||||
/// 找到了命令行,但没跑出列表。
|
||||
CliFailed,
|
||||
/// 跑出来了,但不合格或者没有一条能选的模型。
|
||||
NothingUsable,
|
||||
}
|
||||
|
||||
impl BundledSkip {
|
||||
/// 给前端的原因码(`provider.officialModelsReason.<码>`)。
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::NoCli => "noCli",
|
||||
Self::CliFailed => "cliFailed",
|
||||
Self::NothingUsable => "nothingUsable",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for BundledSkip {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::NoCli => "找不到 Codex 命令行",
|
||||
Self::CliFailed => "Codex 命令行没有输出列表",
|
||||
Self::NothingUsable => "列表里没有能用的模型",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 最近一次切换用的是哪种来源(界面提示用)。没用上的来源带着原因。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NativeSource {
|
||||
Fetched,
|
||||
Bundled,
|
||||
Unavailable,
|
||||
Bundled(OfficialSkip),
|
||||
Unavailable {
|
||||
official: OfficialSkip,
|
||||
bundled: BundledSkip,
|
||||
},
|
||||
/// 拉到的官方列表里没有一条能选的模型:本机 Codex 太旧,服务端只回了隐藏条目。官方
|
||||
/// 行退回自带列表(也没有能选的就不写目录),界面提示升级 Codex。
|
||||
Outdated,
|
||||
@@ -446,21 +542,44 @@ pub(crate) fn last_source() -> Option<NativeSource> {
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
/// 最近一次写目录时官方列表没用上(登录过期、拿不到版本、拉取失败……)。
|
||||
fn fell_back_from_official() -> bool {
|
||||
matches!(
|
||||
last_source(),
|
||||
Some(NativeSource::Bundled(_) | NativeSource::Unavailable { .. })
|
||||
)
|
||||
}
|
||||
|
||||
/// 切换时(拿着切换锁、在写锁之前)按目标登录取官方行。能用而新的条目直接用;能用而
|
||||
/// 过期的先照用、后台刷新;没有能用的就联网拉一次(10 秒),拉不到退回自带列表。
|
||||
pub(crate) fn rows_for_switch(login: Option<&OfficialLogin>) -> NativeRows {
|
||||
/// `login` 取不到时带着原因,和自带列表的失败原因一起记进 [`last_source`]。
|
||||
pub(crate) fn rows_for_switch(login: &Result<OfficialLogin, OfficialSkip>) -> NativeRows {
|
||||
let env = env();
|
||||
let (rows, source) = match login.and_then(|login| official_rows(&env, login)) {
|
||||
Some(Official::Listed(rows)) => (rows, NativeSource::Fetched),
|
||||
Some(Official::NothingListed) => (bundled_rows(&env), NativeSource::Outdated),
|
||||
None => {
|
||||
let rows = bundled_rows(&env);
|
||||
let source = match &rows {
|
||||
NativeRows::Unavailable => NativeSource::Unavailable,
|
||||
_ => NativeSource::Bundled,
|
||||
};
|
||||
(rows, source)
|
||||
}
|
||||
let official = match login {
|
||||
Ok(login) => official_rows(&env, login),
|
||||
Err(skip) => Err(*skip),
|
||||
};
|
||||
let (rows, source) = match official {
|
||||
Ok(Official::Listed(rows)) => (rows, NativeSource::Fetched),
|
||||
Ok(Official::NothingListed) => (
|
||||
bundled_rows(&env).unwrap_or(NativeRows::Unavailable),
|
||||
NativeSource::Outdated,
|
||||
),
|
||||
Err(official) => match bundled_rows(&env) {
|
||||
Ok(rows) => {
|
||||
log::warn!("没用上 Codex 官方模型列表({official}),改用自带列表");
|
||||
(rows, NativeSource::Bundled(official))
|
||||
}
|
||||
Err(bundled) => {
|
||||
log::warn!(
|
||||
"Codex 模型列表都没取到,聚合的模型暂不可用:官方列表{official},自带列表{bundled}"
|
||||
);
|
||||
(
|
||||
NativeRows::Unavailable,
|
||||
NativeSource::Unavailable { official, bundled },
|
||||
)
|
||||
}
|
||||
},
|
||||
};
|
||||
*last_source_slot()
|
||||
.lock()
|
||||
@@ -482,8 +601,8 @@ fn has_listed_model(rows: &[Value]) -> bool {
|
||||
.any(|row| row.get("visibility").and_then(Value::as_str) == Some("list"))
|
||||
}
|
||||
|
||||
fn official_rows(env: &Env, login: &OfficialLogin) -> Option<Official> {
|
||||
let version = (env.codex_version)()?;
|
||||
fn official_rows(env: &Env, login: &OfficialLogin) -> Result<Official, OfficialSkip> {
|
||||
let version = (env.codex_version)().ok_or(OfficialSkip::NoVersion)?;
|
||||
let now = (env.now)();
|
||||
let listed = |rows: Vec<Value>| {
|
||||
if has_listed_model(&rows) {
|
||||
@@ -504,15 +623,15 @@ fn official_rows(env: &Env, login: &OfficialLogin) -> Option<Official> {
|
||||
// 这次的目标登录:切换还没落定,事后重新预测会得到切换前的登录。
|
||||
spawn_refresh(login.clone(), version.clone());
|
||||
}
|
||||
return Some(listed(rows));
|
||||
return Ok(listed(rows));
|
||||
}
|
||||
log::warn!("Codex 官方模型缓存里的列表不合格,重新拉取");
|
||||
}
|
||||
match (env.fetch)(login, &version, None) {
|
||||
Fetch::Models { models, etag } => {
|
||||
let rows = normalize_codex_native_rows(models.clone()).or_else(|| {
|
||||
let rows = normalize_codex_native_rows(models.clone()).ok_or_else(|| {
|
||||
log::warn!("Codex 官方模型列表不合格(缺必填字段或指令字段),改用自带列表");
|
||||
None
|
||||
OfficialSkip::InvalidList
|
||||
})?;
|
||||
// 没有可选模型的列表也存:同一版本再拉还是这样,不必每次切换都等网络。
|
||||
store_entry(
|
||||
@@ -524,29 +643,31 @@ fn official_rows(env: &Env, login: &OfficialLogin) -> Option<Official> {
|
||||
models,
|
||||
},
|
||||
);
|
||||
Some(listed(rows))
|
||||
Ok(listed(rows))
|
||||
}
|
||||
Fetch::NotModified => None,
|
||||
// 没带 etag,不该收到 304。
|
||||
Fetch::NotModified => Err(OfficialSkip::FetchFailed),
|
||||
Fetch::Failed(error) => {
|
||||
log::warn!("拉取 Codex 官方模型列表失败,改用自带列表: {error}");
|
||||
None
|
||||
Err(OfficialSkip::FetchFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn bundled_rows(env: &Env) -> NativeRows {
|
||||
match (env.bundled)()
|
||||
.and_then(normalize_codex_native_rows)
|
||||
fn bundled_rows(env: &Env) -> Result<NativeRows, BundledSkip> {
|
||||
let rows = (env.bundled)()?;
|
||||
normalize_codex_native_rows(rows)
|
||||
.filter(|rows| has_listed_model(rows))
|
||||
{
|
||||
Some(rows) => NativeRows::Bundled { rows },
|
||||
None => NativeRows::Unavailable,
|
||||
}
|
||||
.map(|rows| NativeRows::Bundled { rows })
|
||||
.ok_or(BundledSkip::NothingUsable)
|
||||
}
|
||||
|
||||
/// 刷新一个身份的条目:一定联网,带 `If-None-Match`。304 只更新 `fetched_at`;失败保留
|
||||
/// 旧条目和旧的 `fetched_at`,等下一个检查点再试。返回列表有没有变;变了同时记下客户端
|
||||
/// 落后于缓存(见 [`take_client_behind`])。
|
||||
///
|
||||
/// 上次写目录时官方列表没用上,这次又取到了,列表没变也记下落后:比如当时 Codex 的登录
|
||||
/// 过期,之后它自己刷新了登录,缓存里的旧列表照样是 304,不这样客户端会一直停在自带列表上。
|
||||
pub(crate) fn refresh(login: &OfficialLogin, version: &str) -> Result<bool, String> {
|
||||
let env = env();
|
||||
let now = (env.now)();
|
||||
@@ -562,6 +683,9 @@ pub(crate) fn refresh(login: &OfficialLogin, version: &str) -> Result<bool, Stri
|
||||
..entry
|
||||
},
|
||||
);
|
||||
if fell_back_from_official() {
|
||||
mark_client_behind();
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
@@ -579,7 +703,7 @@ pub(crate) fn refresh(login: &OfficialLogin, version: &str) -> Result<bool, Stri
|
||||
models,
|
||||
},
|
||||
);
|
||||
if changed {
|
||||
if changed || fell_back_from_official() {
|
||||
mark_client_behind();
|
||||
}
|
||||
Ok(changed)
|
||||
@@ -598,6 +722,25 @@ pub(crate) fn needs_refresh(login: &OfficialLogin) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 后台检查不用刷新时(条目还新,或拿不到版本)调:上次写目录时官方列表没用上,而这个
|
||||
/// 登录现在有能用的条目,就记下客户端落后,让检查按它重写。不联网。不补的话要等条目过了
|
||||
/// 新鲜期、刷新拿到 304 才重写(见 [`refresh`]),最多晚 6 小时:比如写目录那一刻钥匙串没读
|
||||
/// 出来,过一会儿又读得出了。
|
||||
pub(crate) fn catch_up_after_fallback(login: &OfficialLogin) {
|
||||
if !fell_back_from_official() {
|
||||
return;
|
||||
}
|
||||
let Some(version) = (env().codex_version)() else {
|
||||
return;
|
||||
};
|
||||
let usable = usable_entry(&login.identity, &version)
|
||||
.and_then(|entry| normalize_codex_native_rows(entry.models))
|
||||
.is_some();
|
||||
if usable {
|
||||
mark_client_behind();
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换时发现条目过期:后台刷新,列表变了就重写 Codex 的客户端文件。
|
||||
fn spawn_refresh(login: OfficialLogin, version: String) {
|
||||
std::thread::spawn(move || match refresh(&login, &version) {
|
||||
@@ -703,7 +846,7 @@ pub(crate) mod testing {
|
||||
}
|
||||
response
|
||||
}),
|
||||
bundled: Box::new(move || bundled.clone()),
|
||||
bundled: Box::new(move || bundled.clone().ok_or(BundledSkip::NoCli)),
|
||||
keychain: Box::new(move || {
|
||||
keychain_reads.fetch_add(1, Ordering::SeqCst);
|
||||
keychain.lock().unwrap().clone()
|
||||
@@ -884,13 +1027,31 @@ mod tests {
|
||||
// 没有 id_token:身份不稳定,不拉取。
|
||||
let mut no_id = login("ws-1", "alice");
|
||||
no_id["tokens"].as_object_mut().unwrap().remove("id_token");
|
||||
assert!(OfficialLogin::of(&no_id).is_none());
|
||||
assert_eq!(
|
||||
OfficialLogin::of(&no_id).err(),
|
||||
Some(OfficialSkip::LoginIncomplete)
|
||||
);
|
||||
let mut no_workspace = login("ws-1", "alice");
|
||||
no_workspace["tokens"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("account_id");
|
||||
assert_eq!(
|
||||
OfficialLogin::of(&no_workspace).err(),
|
||||
Some(OfficialSkip::LoginIncomplete)
|
||||
);
|
||||
// 很久没刷新的 token 不用,等 Codex 自己刷新。
|
||||
let mut stale = login("ws-1", "alice");
|
||||
stale["last_refresh"] = json!("2026-01-01T00:00:00Z");
|
||||
assert!(OfficialLogin::of(&stale).is_none());
|
||||
assert_eq!(
|
||||
OfficialLogin::of(&stale).err(),
|
||||
Some(OfficialSkip::LoginStale)
|
||||
);
|
||||
// API Key 登录没有官方列表。
|
||||
assert!(OfficialLogin::of(&json!({ "OPENAI_API_KEY": "sk" })).is_none());
|
||||
assert_eq!(
|
||||
OfficialLogin::of(&json!({ "OPENAI_API_KEY": "sk" })).err(),
|
||||
Some(OfficialSkip::NoLogin)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -900,7 +1061,7 @@ mod tests {
|
||||
let setup = install(Some("0.158.0"), vec![fetched(&["gpt-6-sol"])], None);
|
||||
let login = OfficialLogin::of(&login("ws", "alice")).unwrap();
|
||||
|
||||
let rows = rows_for_switch(Some(&login));
|
||||
let rows = rows_for_switch(&Ok(login.clone()));
|
||||
assert_eq!(slugs(&rows), vec!["gpt-6-sol"]);
|
||||
assert_eq!(rows.identity(), Some("ws|sub:alice"));
|
||||
assert_eq!(
|
||||
@@ -911,7 +1072,10 @@ mod tests {
|
||||
|
||||
// 一小时后:新的,不联网。
|
||||
*setup.now.lock().unwrap() = T0 + HOUR;
|
||||
assert_eq!(slugs(&rows_for_switch(Some(&login))), vec!["gpt-6-sol"]);
|
||||
assert_eq!(
|
||||
slugs(&rows_for_switch(&Ok(login.clone()))),
|
||||
vec!["gpt-6-sol"]
|
||||
);
|
||||
assert_eq!(setup.calls.lock().unwrap().len(), 1);
|
||||
assert!(needs_refresh(&login).is_none());
|
||||
}
|
||||
@@ -924,7 +1088,7 @@ mod tests {
|
||||
seed_cache("ws|sub:bob", T0, "0.158.0", models(&["bobs"]));
|
||||
install(Some("0.158.0"), vec![fetched(&["alices"])], None);
|
||||
let alice = OfficialLogin::of(&login("ws", "alice")).unwrap();
|
||||
assert_eq!(slugs(&rows_for_switch(Some(&alice))), vec!["alices"]);
|
||||
assert_eq!(slugs(&rows_for_switch(&Ok(alice.clone()))), vec!["alices"]);
|
||||
// 各存各的,互不覆盖。
|
||||
assert_eq!(cache_fetched_at("ws|sub:bob"), Some(T0));
|
||||
}
|
||||
@@ -949,7 +1113,7 @@ mod tests {
|
||||
let login = OfficialLogin::of(&login("ws", "alice")).unwrap();
|
||||
|
||||
// 这次先用旧条目完成切换,不等网络。
|
||||
assert_eq!(slugs(&rows_for_switch(Some(&login))), vec!["cached"]);
|
||||
assert_eq!(slugs(&rows_for_switch(&Ok(login.clone()))), vec!["cached"]);
|
||||
rx.recv_timeout(Duration::from_secs(5))
|
||||
.expect("background refresh");
|
||||
// 刷新带的是这次的目标登录和旧条目的 etag。
|
||||
@@ -969,7 +1133,7 @@ mod tests {
|
||||
}
|
||||
assert_eq!(cache_fetched_at("ws|sub:alice"), Some(T0));
|
||||
assert_eq!(
|
||||
slugs(&rows_for_switch(Some(&login))),
|
||||
slugs(&rows_for_switch(&Ok(login.clone()))),
|
||||
vec!["cached", "new-model"]
|
||||
);
|
||||
// 后台检查还没开始(CC Switch 正在启动)时刷新完了:不重写,标记留给第一次检查。
|
||||
@@ -1029,6 +1193,66 @@ mod tests {
|
||||
assert!(setup.responses.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// 上次写目录时没用上官方列表,而登录恢复后缓存里的条目还新:后台检查不联网,直接按它
|
||||
/// 重写;用上之后不再重写。
|
||||
#[test]
|
||||
#[serial]
|
||||
fn a_fresh_entry_after_falling_back_rewrites_the_client_without_fetching() {
|
||||
let _scope = Scope::new();
|
||||
seed_cache("ws|sub:alice", T0 - HOUR, "0.158.0", models(&["cached"]));
|
||||
let setup = install(Some("0.158.0"), Vec::new(), Some(models(&["bundled"])));
|
||||
let bob = OfficialLogin::of(&login("ws", "bob")).unwrap();
|
||||
let alice = OfficialLogin::of(&login("ws", "alice")).unwrap();
|
||||
|
||||
// 还没写过目录、上次用的就是官方列表:都不补。
|
||||
catch_up_after_fallback(&alice);
|
||||
assert!(!take_client_behind());
|
||||
rows_for_switch(&Ok(alice.clone()));
|
||||
assert_eq!(last_source(), Some(NativeSource::Fetched));
|
||||
catch_up_after_fallback(&alice);
|
||||
assert!(!take_client_behind());
|
||||
|
||||
rows_for_switch(&Err(OfficialSkip::NoLogin));
|
||||
assert_eq!(needs_refresh(&alice), None, "the entry is still fresh");
|
||||
catch_up_after_fallback(&alice);
|
||||
assert!(take_client_behind());
|
||||
assert!(setup.calls.lock().unwrap().is_empty());
|
||||
|
||||
// 别人的登录在缓存里没有条目:不补。
|
||||
rows_for_switch(&Err(OfficialSkip::NoLogin));
|
||||
catch_up_after_fallback(&bob);
|
||||
assert!(!take_client_behind());
|
||||
}
|
||||
|
||||
/// 上次写目录时登录过期、退回了自带列表;Codex 之后自己刷新了登录,后台刷新拿到的还是
|
||||
/// 缓存里那份(304),客户端也要重写,不能一直停在自带列表上。
|
||||
#[test]
|
||||
#[serial]
|
||||
fn a_refresh_after_falling_back_rewrites_the_client_even_if_nothing_changed() {
|
||||
let _scope = Scope::new();
|
||||
seed_cache(
|
||||
"ws|sub:alice",
|
||||
T0 - 7 * HOUR,
|
||||
"0.158.0",
|
||||
models(&["cached"]),
|
||||
);
|
||||
install(
|
||||
Some("0.158.0"),
|
||||
vec![Fetch::NotModified, fetched(&["cached"])],
|
||||
Some(models(&["bundled"])),
|
||||
);
|
||||
let login = OfficialLogin::of(&login("ws", "alice")).unwrap();
|
||||
|
||||
rows_for_switch(&Err(OfficialSkip::LoginStale));
|
||||
assert_eq!(refresh(&login, "0.158.0"), Ok(false));
|
||||
assert!(take_client_behind(), "304 after a fallback");
|
||||
|
||||
// 重写之后用上了官方列表:再刷新到同样的列表就不必重写。
|
||||
assert_eq!(slugs(&rows_for_switch(&Ok(login.clone()))), vec!["cached"]);
|
||||
assert_eq!(refresh(&login, "0.158.0"), Ok(false));
|
||||
assert!(!take_client_behind());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn without_a_usable_official_list_the_bundled_list_is_used() {
|
||||
@@ -1038,25 +1262,43 @@ mod tests {
|
||||
|
||||
// 拿不到版本:不请求。
|
||||
let setup = install(None, vec![fetched(&["x"])], bundled.clone());
|
||||
assert_eq!(slugs(&rows_for_switch(Some(&login))), vec!["bundled"]);
|
||||
assert_eq!(slugs(&rows_for_switch(&Ok(login.clone()))), vec!["bundled"]);
|
||||
assert!(setup.calls.lock().unwrap().is_empty());
|
||||
assert_eq!(last_source(), Some(NativeSource::Bundled));
|
||||
assert_eq!(
|
||||
last_source(),
|
||||
Some(NativeSource::Bundled(OfficialSkip::NoVersion))
|
||||
);
|
||||
|
||||
// 没有能用的登录、请求失败、列表不合格:都退回自带列表。
|
||||
// 没有能用的登录、请求失败、列表不合格:都退回自带列表,记下原因。
|
||||
install(Some("0.158.0"), Vec::new(), bundled.clone());
|
||||
assert_eq!(slugs(&rows_for_switch(None)), vec!["bundled"]);
|
||||
assert_eq!(
|
||||
slugs(&rows_for_switch(&Err(OfficialSkip::LoginStale))),
|
||||
vec!["bundled"]
|
||||
);
|
||||
assert_eq!(
|
||||
last_source(),
|
||||
Some(NativeSource::Bundled(OfficialSkip::LoginStale))
|
||||
);
|
||||
install(
|
||||
Some("0.158.0"),
|
||||
vec![Fetch::Failed("401".to_string())],
|
||||
bundled.clone(),
|
||||
);
|
||||
assert_eq!(slugs(&rows_for_switch(Some(&login))), vec!["bundled"]);
|
||||
assert_eq!(slugs(&rows_for_switch(&Ok(login.clone()))), vec!["bundled"]);
|
||||
assert_eq!(
|
||||
last_source(),
|
||||
Some(NativeSource::Bundled(OfficialSkip::FetchFailed))
|
||||
);
|
||||
let invalid = Fetch::Models {
|
||||
models: vec![json!({ "slug": "no-instructions" })],
|
||||
etag: None,
|
||||
};
|
||||
install(Some("0.158.0"), vec![invalid], bundled);
|
||||
assert_eq!(slugs(&rows_for_switch(Some(&login))), vec!["bundled"]);
|
||||
assert_eq!(slugs(&rows_for_switch(&Ok(login.clone()))), vec!["bundled"]);
|
||||
assert_eq!(
|
||||
last_source(),
|
||||
Some(NativeSource::Bundled(OfficialSkip::InvalidList))
|
||||
);
|
||||
assert!(
|
||||
cache_fetched_at("ws|sub:alice").is_none(),
|
||||
"an invalid list is not cached"
|
||||
@@ -1064,8 +1306,14 @@ mod tests {
|
||||
|
||||
// 两个来源都没有:不可用。
|
||||
install(Some("0.158.0"), Vec::new(), None);
|
||||
assert_eq!(rows_for_switch(Some(&login)), NativeRows::Unavailable);
|
||||
assert_eq!(last_source(), Some(NativeSource::Unavailable));
|
||||
assert_eq!(rows_for_switch(&Ok(login.clone())), NativeRows::Unavailable);
|
||||
assert_eq!(
|
||||
last_source(),
|
||||
Some(NativeSource::Unavailable {
|
||||
official: OfficialSkip::FetchFailed,
|
||||
bundled: BundledSkip::NoCli,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fn hidden(rows: Vec<Value>) -> Vec<Value> {
|
||||
@@ -1094,10 +1342,10 @@ mod tests {
|
||||
vec![only_hidden],
|
||||
Some(models(&["bundled"])),
|
||||
);
|
||||
assert_eq!(slugs(&rows_for_switch(Some(&login))), vec!["bundled"]);
|
||||
assert_eq!(slugs(&rows_for_switch(&Ok(login.clone()))), vec!["bundled"]);
|
||||
assert_eq!(last_source(), Some(NativeSource::Outdated));
|
||||
assert!(cache_fetched_at("ws|sub:alice").is_some());
|
||||
assert_eq!(slugs(&rows_for_switch(Some(&login))), vec!["bundled"]);
|
||||
assert_eq!(slugs(&rows_for_switch(&Ok(login.clone()))), vec!["bundled"]);
|
||||
assert_eq!(setup.calls.lock().unwrap().len(), 1);
|
||||
assert_eq!(last_source(), Some(NativeSource::Outdated));
|
||||
|
||||
@@ -1107,12 +1355,21 @@ mod tests {
|
||||
Vec::new(),
|
||||
Some(hidden(models(&["bundled"]))),
|
||||
);
|
||||
assert_eq!(rows_for_switch(Some(&login)), NativeRows::Unavailable);
|
||||
assert_eq!(rows_for_switch(&Ok(login.clone())), NativeRows::Unavailable);
|
||||
assert_eq!(last_source(), Some(NativeSource::Outdated));
|
||||
|
||||
// 没有登录、只有全隐藏的自带列表:不可用。
|
||||
assert_eq!(rows_for_switch(None), NativeRows::Unavailable);
|
||||
assert_eq!(last_source(), Some(NativeSource::Unavailable));
|
||||
assert_eq!(
|
||||
rows_for_switch(&Err(OfficialSkip::NoLogin)),
|
||||
NativeRows::Unavailable
|
||||
);
|
||||
assert_eq!(
|
||||
last_source(),
|
||||
Some(NativeSource::Unavailable {
|
||||
official: OfficialSkip::NoLogin,
|
||||
bundled: BundledSkip::NothingUsable,
|
||||
})
|
||||
);
|
||||
|
||||
// 有一行能选就算合格,隐藏的行照样保留(官方自己就这样发)。
|
||||
let mut mixed = hidden(models(&["codex-auto-review"]));
|
||||
@@ -1126,7 +1383,7 @@ mod tests {
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
slugs(&rows_for_switch(Some(&login))),
|
||||
slugs(&rows_for_switch(&Ok(login.clone()))),
|
||||
vec!["codex-auto-review", "gpt-6-sol"]
|
||||
);
|
||||
assert_eq!(last_source(), Some(NativeSource::Fetched));
|
||||
@@ -1237,6 +1494,6 @@ mod tests {
|
||||
.unwrap();
|
||||
install(Some("0.158.0"), Vec::new(), None);
|
||||
let login = OfficialLogin::of(&login("ws", "alice")).unwrap();
|
||||
assert_eq!(rows_for_switch(Some(&login)), NativeRows::Unavailable);
|
||||
assert_eq!(rows_for_switch(&Ok(login.clone())), NativeRows::Unavailable);
|
||||
}
|
||||
}
|
||||
@@ -450,7 +450,15 @@ export function SwitchModePanel({
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>,
|
||||
>
|
||||
{stack.noticeReasons?.length
|
||||
? stack.noticeReasons.map((reason) => (
|
||||
<div key={reason}>
|
||||
{t(`provider.officialModelsReason.${reason}`)}
|
||||
</div>
|
||||
))
|
||||
: undefined}
|
||||
</Notice>,
|
||||
);
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -127,6 +127,17 @@
|
||||
"officialModelsBundled": "The official model list isn't available yet, so Codex's built-in list is used for now and account-specific models may be missing",
|
||||
"officialModelsUnavailable": "Aggregated models are unavailable for now: the Codex model list can't be read",
|
||||
"officialModelsOutdated": "Codex on this computer is too old to get the full official model list: update Codex (including the CLI) to the latest version",
|
||||
"officialModelsReason": {
|
||||
"noLogin": "Official list: Codex has no usable ChatGPT login. Sign in to Codex with a ChatGPT account",
|
||||
"loginStale": "Official list: the Codex login hasn't been refreshed for over 8 days. Use Codex once and it refreshes automatically",
|
||||
"loginIncomplete": "Official list: the Codex login is incomplete (missing the workspace or user ID). Sign in to Codex again",
|
||||
"noVersion": "Official list: can't read the Codex version on this computer. It will retry after you start a conversation in Codex",
|
||||
"fetchFailed": "Official list: fetching it failed; retrying every 15 minutes (see the log for details)",
|
||||
"invalidList": "Official list: the returned data is incomplete (see the log for details)",
|
||||
"noCli": "Codex built-in list: no Codex CLI found on this computer",
|
||||
"cliFailed": "Codex built-in list: the Codex CLI didn't output a model list (see the log for details)",
|
||||
"nothingUsable": "Codex built-in list: it has no usable models. Update Codex to the latest version"
|
||||
},
|
||||
"routeOwnsCatalog": "Aggregated models won't show up in Codex: the routed provider uses its own model catalog file (model_catalog_json)",
|
||||
"adoptCatalog": "Use CC Switch's model catalog",
|
||||
"adoptCatalogDone": "Now using CC Switch's model catalog. Restart Codex to apply",
|
||||
|
||||
@@ -127,6 +127,17 @@
|
||||
"officialModelsBundled": "公式モデル一覧をまだ取得できていないため、Codex 内蔵の一覧を使っています。アカウント専用のモデルが表示されない場合があります",
|
||||
"officialModelsUnavailable": "Codex のモデル一覧を読み込めないため、集約したモデルは現在使用できません",
|
||||
"officialModelsOutdated": "このコンピューターの Codex が古いため、公式モデル一覧を完全に取得できません。Codex(CLI を含む)を最新版に更新してください",
|
||||
"officialModelsReason": {
|
||||
"noLogin": "公式一覧:Codex に使える ChatGPT ログインがありません。Codex で ChatGPT アカウントにログインしてください",
|
||||
"loginStale": "公式一覧:Codex のログインが 8 日以上更新されていません。Codex を一度使うと自動で更新されます",
|
||||
"loginIncomplete": "公式一覧:Codex のログイン情報が不完全です(ワークスペースまたはユーザー ID がありません)。Codex でもう一度ログインしてください",
|
||||
"noVersion": "公式一覧:このコンピューターの Codex のバージョンを読み取れません。Codex で会話を始めると自動で再試行します",
|
||||
"fetchFailed": "公式一覧:取得に失敗しました。15 分ごとに自動で再試行します(詳細はログを参照)",
|
||||
"invalidList": "公式一覧:返されたデータが不完全です(詳細はログを参照)",
|
||||
"noCli": "Codex 内蔵の一覧:このコンピューターに Codex CLI が見つかりません",
|
||||
"cliFailed": "Codex 内蔵の一覧:Codex CLI がモデル一覧を出力しませんでした(詳細はログを参照)",
|
||||
"nothingUsable": "Codex 内蔵の一覧:使えるモデルがありません。Codex を最新版に更新してください"
|
||||
},
|
||||
"routeOwnsCatalog": "ルーティング先のプロバイダーが独自のモデルカタログファイル(model_catalog_json)を使っているため、集約したモデルは Codex に表示されません",
|
||||
"adoptCatalog": "CC Switch のモデルカタログを使う",
|
||||
"adoptCatalogDone": "CC Switch のモデルカタログに切り替えました。Codex を再起動すると反映されます",
|
||||
|
||||
@@ -127,6 +127,17 @@
|
||||
"officialModelsBundled": "官方模型列表暫未取得,暫用 Codex 內建列表,可能缺少帳號專屬模型",
|
||||
"officialModelsUnavailable": "聚合的模型暫不可用:讀取不到 Codex 模型列表",
|
||||
"officialModelsOutdated": "本機 Codex 版本過舊,無法取得完整的官方模型列表:請把 Codex(包括命令列)升級到最新版",
|
||||
"officialModelsReason": {
|
||||
"noLogin": "官方列表:Codex 沒有可用的 ChatGPT 登入,請在 Codex 裡用 ChatGPT 帳號登入",
|
||||
"loginStale": "官方列表:Codex 的登入超過 8 天沒有重新整理,開啟 Codex 用一下即可自動重新整理",
|
||||
"loginIncomplete": "官方列表:Codex 的登入資訊不完整(缺少工作區或使用者 ID),請在 Codex 裡重新登入",
|
||||
"noVersion": "官方列表:讀不到本機 Codex 的版本,用 Codex 開始一次對話後會自動重試",
|
||||
"fetchFailed": "官方列表:連網取得失敗,會每 15 分鐘自動重試(詳情見日誌)",
|
||||
"invalidList": "官方列表:傳回的資料不完整(詳情見日誌)",
|
||||
"noCli": "Codex 內建列表:本機找不到 Codex 命令列",
|
||||
"cliFailed": "Codex 內建列表:Codex 命令列沒能輸出模型列表(詳情見日誌)",
|
||||
"nothingUsable": "Codex 內建列表:裡面沒有能用的模型,請把 Codex 升級到最新版"
|
||||
},
|
||||
"routeOwnsCatalog": "聚合的模型不會出現在 Codex 中:路由供應商使用自己的模型目錄檔案(model_catalog_json)",
|
||||
"adoptCatalog": "改用 CC Switch 的模型目錄",
|
||||
"adoptCatalogDone": "已改用 CC Switch 的模型目錄,重新啟動 Codex 後生效",
|
||||
|
||||
@@ -127,6 +127,17 @@
|
||||
"officialModelsBundled": "官方模型列表暂未取到,暂用 Codex 内置列表,可能缺少账号专属模型",
|
||||
"officialModelsUnavailable": "聚合的模型暂不可用:读取不到 Codex 模型列表",
|
||||
"officialModelsOutdated": "本机 Codex 版本过旧,拿不到完整的官方模型列表:请把 Codex(包括命令行)升级到最新版",
|
||||
"officialModelsReason": {
|
||||
"noLogin": "官方列表:Codex 没有可用的 ChatGPT 登录,请在 Codex 里用 ChatGPT 账号登录",
|
||||
"loginStale": "官方列表:Codex 的登录超过 8 天没有刷新,打开 Codex 用一下即可自动刷新",
|
||||
"loginIncomplete": "官方列表:Codex 的登录信息不完整(缺少工作区或用户 ID),请在 Codex 里重新登录",
|
||||
"noVersion": "官方列表:读不到本机 Codex 的版本,用 Codex 开始一次对话后会自动重试",
|
||||
"fetchFailed": "官方列表:联网获取失败,会每 15 分钟自动重试(详情见日志)",
|
||||
"invalidList": "官方列表:返回的数据不完整(详情见日志)",
|
||||
"noCli": "Codex 自带列表:本机找不到 Codex 命令行",
|
||||
"cliFailed": "Codex 自带列表:Codex 命令行没能输出模型列表(详情见日志)",
|
||||
"nothingUsable": "Codex 自带列表:里面没有能用的模型,请把 Codex 升级到最新版"
|
||||
},
|
||||
"routeOwnsCatalog": "聚合的模型不会出现在 Codex 里:路由供应商使用自己的模型目录文件(model_catalog_json)",
|
||||
"adoptCatalog": "改用 CC Switch 的模型目录",
|
||||
"adoptCatalogDone": "已改用 CC Switch 的模型目录,重启 Codex 后生效",
|
||||
|
||||
@@ -93,6 +93,21 @@ export type ProxyStackNotice =
|
||||
| "officialModelsUnavailable"
|
||||
| "officialModelsOutdated";
|
||||
|
||||
/**
|
||||
* 官方模型列表没取到的原因(`officialModelsBundled` / `officialModelsUnavailable` 时):先是官方
|
||||
* 列表的,不可用时再跟 Codex 自带列表的。
|
||||
*/
|
||||
export type ProxyStackNoticeReason =
|
||||
| "noLogin"
|
||||
| "loginStale"
|
||||
| "loginIncomplete"
|
||||
| "noVersion"
|
||||
| "fetchFailed"
|
||||
| "invalidList"
|
||||
| "noCli"
|
||||
| "cliFailed"
|
||||
| "nothingUsable";
|
||||
|
||||
/**
|
||||
* 还在用旧模型列表的 Codex 客户端(它们只在启动时读模型目录):`daemon` 是 `codex` 命令行连的
|
||||
* 托管守护进程,可以一键重启;`others` 是桌面版、编辑器插件,要用户自己彻底退出再开。
|
||||
@@ -115,6 +130,7 @@ export interface ProxyStack {
|
||||
active: boolean;
|
||||
members: ProxyStackMember[];
|
||||
notice?: ProxyStackNotice;
|
||||
noticeReasons?: ProxyStackNoticeReason[];
|
||||
staleClients?: CodexStaleClients;
|
||||
/** `staleClients` 对应的目录和登录:关掉提示后这个值变了(同一个供应商上又改了目录),重新显示。 */
|
||||
staleRevision?: string;
|
||||
|
||||
@@ -150,6 +150,31 @@ describe("SwitchModePanel — Stack mode", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("says why the Codex model list could not be read", async () => {
|
||||
mockMode("stack", "route");
|
||||
server.use(
|
||||
http.post(`${TAURI_ENDPOINT}/get_proxy_stack`, () =>
|
||||
HttpResponse.json({
|
||||
active: true,
|
||||
members: [{ providerId: "route", modelIds: [], route: true }],
|
||||
notice: "officialModelsUnavailable",
|
||||
noticeReasons: ["loginStale", "noCli"],
|
||||
}),
|
||||
),
|
||||
);
|
||||
renderPanel("codex", { route: provider("route") });
|
||||
|
||||
expect(
|
||||
await screen.findByText("provider.officialModelsUnavailable"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("provider.officialModelsReason.loginStale"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("provider.officialModelsReason.noCli"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns about Codex clients on an old model list in every mode", async () => {
|
||||
const stack = (active: boolean) =>
|
||||
http.post(`${TAURI_ENDPOINT}/get_proxy_stack`, () =>
|
||||
|
||||
Reference in new issue
Block a user