fix(proxy): report routed switches without unconditional restart prompts (#8378)

Route switches now return what was actually applied (remembered / route / stack) and whether the client configuration was rewritten, and the success toast is built from that instead of always asking Codex / Gemini CLI / Grok Build users to restart. A restart hint is shown only when client files changed. Direct-mode switches serialize unchanged. Fixes #8347.
This commit is contained in:
SmileMachine authored and GitHub committed 2026-10-17 21:40:27 +08:00
1 parent d23cee4c11
commit 5cc94b8dac
21 files changed
+602 -33

No files matched your search

+2 -2
View File
@@ -143,9 +143,9 @@ When switching providers in routing mode:
1. In the "Routing" tab, click "Route here" on a provider card (with failover on, adjust the queue instead; see [4.3 Failover](./4.3-failover.md))
2. The routing service immediately uses the new provider to forward requests
3. **No need to restart the CLI tool**
3. **No need to restart the CLI tool if its configuration is unchanged**
This is a major advantage of routing mode: provider switching takes effect instantly. After switching Codex, Gemini CLI, or Grok Build, the interface still prompts you to restart; while routing, you can skip the restart as long as the switch doesn't change the model.
After a route switch, new requests use the new provider; requests already in progress are unchanged. The success notification distinguishes the route change from client configuration updates: changing only the upstream needs no restart. If client configuration also changes, restart Codex, Gemini CLI, or Grok Build to load that configuration.
In routing mode, switching changes only the provider that routing uses; the direct provider stays the same, is labeled "Direct" on its card, and is what you return to when you leave routing. If what the new provider writes to the config file (the local address, model name, etc.) is the same as before, switching doesn't touch the config file; if it differs (for example, a different model name), CC Switch updates the config file first, and Codex, Gemini CLI, and Grok Build then need a restart to use the new model.
+2 -2
View File
@@ -143,9 +143,9 @@ CC Switch を終了するときも、まず設定ファイルを直接接続の
1. 「ルーティング」タブでプロバイダーカードの「ここへ転送」をクリック(フェイルオーバー有効時は、キューで調整します。[4.3 フェイルオーバー](./4.3-failover.md) を参照)
2. ルーティングサービスが新しいプロバイダーを使用してリクエストを即座に転送
3. **CLI ツールの再起動は不要**
3. **クライアント設定が変わらない場合、CLI ツールの再起動は不要**
これがルーティングモードの大きなメリットです:プロバイダーの切り替えが即座に反映されます。Codex、Gemini CLI、Grok Build を切り替えた後も画面には再起動の案内が表示されますが、ルーティング中で、切り替えによってモデルが変わらない限り、再起動しなくても構いません。
ルーティング先を切り替えると、以降の新しいリクエストは新しいプロバイダーを使用します。処理中のリクエストは変わりません。成功通知では、ルーティング先の変更とクライアント設定の更新を区別します。接続先だけの変更なら再起動は不要です。クライアント設定も更新された場合は、Codex、Gemini CLI、Grok Build を再起動して更新後の設定を読み込んでください。
ルーティングモードで切り替わるのは、ルーティングが使うプロバイダーだけです。直接接続のプロバイダーはそのまま残り、カードに「直接接続」と表示され、ルーティングを抜けるとそのプロバイダーに戻ります。新しいプロバイダーが設定ファイルに書き込む内容(ローカルアドレス、モデル名など)が以前と同じであれば、切り替えても設定ファイルは変更されません。異なる場合(モデル名が違うなど)は、CC Switch が先に設定ファイルを更新します。このとき Codex、Gemini CLI、Grok Build は、新しいモデルを使うために再起動が必要です。
+2 -2
View File
@@ -143,9 +143,9 @@ GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
1. 在「路由」标签里点击供应商卡片上的「路由到此」(故障转移开启时,改在队列里调整,见 [4.3 故障转移](./4.3-failover.md))
2. 路由立即使用新供应商转发请求
3. **无需重启 CLI 工具**
3. **客户端配置未改变时,无需重启 CLI 工具**
这是路由模式的一大优势:切换供应商即时生效。切换 Codex、Gemini CLI 或 Grok Build 后,界面仍会提示重启;路由时,只要切换没有改变模型,就可以不重启。
路由目标切换后,后续新请求会使用新供应商,正在进行的请求不变。成功提示会区分路由切换和客户端配置更新:只改变上游时无需重启;如果同时更新了客户端配置,Codex、Gemini CLI、Grok Build 需要重启才能加载这些配置。
路由模式下切换的只是路由使用的供应商,直连供应商保持不变,卡片上标「直连」,退出路由后回到它。如果新供应商写进配置文件的内容(本地地址、模型名等)和原来一样,切换时不会改动配置文件;不一样时(比如模型名不同),CC Switch 会先更新配置文件,这时 Codex、Gemini CLI、Grok Build 需要重启才能用上新模型。
+1 -1
View File
@@ -113,7 +113,7 @@ pub async fn set_proxy_route(
state: tauri::State<'_, AppState>,
app_type: String,
provider_id: String,
) -> Result<(), String> {
) -> Result<crate::mode::controller::RouteSwitchResult, String> {
let app = require_mode_app(&app_type)?;
let result = crate::mode::controller::set_route(state.inner(), &app, &provider_id).await;
// 已经在路由 / 聚合模式时换的是正在用的那家,托盘跟着变
+213 -15
View File
@@ -1017,12 +1017,29 @@ pub async fn detach_all(state: &AppState) {
}
}
/// What this operation actually applied, computed while holding the per-app switch lock.
/// A client-config rewrite is separate from the proxy's immediately effective upstream target.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RouteSwitchResult {
pub kind: RouteSwitchKind,
pub client_config_changed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub enum RouteSwitchKind {
Remembered,
Route,
Stack,
}
/// 代理模式下手动换路由。调用方持有切换锁。直连指针不变。
pub async fn switch_route_locked(
state: &AppState,
app: &AppType,
target: &Provider,
) -> Result<(), String> {
) -> Result<RouteSwitchResult, String> {
let mode = current::mode_state(app);
if !mode.is_proxy() {
return Err(format!("{} 不在代理模式", app.as_str()));
@@ -1075,7 +1092,16 @@ pub async fn switch_route_locked(
);
}
state.proxy_service.set_active_target(app, target).await;
Ok(())
Ok(RouteSwitchResult {
kind: if !mode.attached {
RouteSwitchKind::Remembered
} else if current.enabled {
RouteSwitchKind::Stack
} else {
RouteSwitchKind::Route
},
client_config_changed: rewritten,
})
}
/// 代理模式下手动换路由(拿切换锁)。不支持代理的官方供应商拒绝切入。
@@ -1114,12 +1140,16 @@ async fn switch_route_checked(
.to_string(),
);
}
switch_route_locked(state, app, &target).await
switch_route_locked(state, app, &target).await.map(|_| ())
}
/// 指定代理路由(聚合模式下是默认那家),不管现在是什么模式。直连模式下只记下指针,下次进入
/// 路由 / 聚合模式时用它,客户端文件和模式都不动;已经在代理模式时就是换路由,当场生效。
pub async fn set_route(state: &AppState, app: &AppType, provider_id: &str) -> Result<(), String> {
pub async fn set_route(
state: &AppState,
app: &AppType,
provider_id: &str,
) -> Result<RouteSwitchResult, String> {
require_proxy_app(app)?;
let target =
provider(state, app, provider_id)?.ok_or_else(|| format!("供应商不存在: {provider_id}"))?;
@@ -1130,7 +1160,10 @@ pub async fn set_route(state: &AppState, app: &AppType, provider_id: &str) -> Re
return switch_route_locked(state, app, &target).await;
}
if mode.proxy_route.as_deref() == Some(provider_id) {
return Ok(());
return Ok(RouteSwitchResult {
kind: RouteSwitchKind::Remembered,
client_config_changed: false,
});
}
let previous = mode.proxy_route.clone();
commit_state(
@@ -1150,7 +1183,10 @@ pub async fn set_route(state: &AppState, app: &AppType, provider_id: &str) -> Re
previous.as_deref().unwrap_or("(无)"),
provider_id
);
Ok(())
Ok(RouteSwitchResult {
kind: RouteSwitchKind::Remembered,
client_config_changed: false,
})
}
/// 代理模式下不能切到不支持代理的官方供应商(Codex 官方账号走客户端自己的登录,除外)。
@@ -1452,7 +1488,7 @@ pub async fn resync_route_locked(state: &AppState, app: &AppType) -> Result<(),
let Some((_, route)) = attached_route(state, app)? else {
return Ok(());
};
switch_route_locked(state, app, &route).await
switch_route_locked(state, app, &route).await.map(|_| ())
}
/// 代理模式下存好了 `provider` 的行:它是代理路由时按新行重写代理契约;在 Stack 名单里时,
@@ -1468,7 +1504,7 @@ pub async fn resync_saved_row_locked(
return Ok(());
}
if mode.routes_to(&provider.id) {
return switch_route_locked(state, app, provider).await;
return switch_route_locked(state, app, provider).await.map(|_| ());
}
let in_stack = stack::is_member(app, &provider.id).unwrap_or_else(|error| {
log::warn!("读取 {} 的 Stack 模型失败: {error}", app.as_str());
@@ -2663,6 +2699,86 @@ mod mode_tests {
assert!(!state.proxy_service.is_running().await);
}
#[test]
fn route_switch_results_serialize_the_renderer_contract() {
for (kind, name) in [
(RouteSwitchKind::Remembered, "remembered"),
(RouteSwitchKind::Route, "route"),
(RouteSwitchKind::Stack, "stack"),
] {
for changed in [false, true] {
let result = RouteSwitchResult {
kind: kind.clone(),
client_config_changed: changed,
};
assert_eq!(
serde_json::to_value(result).unwrap(),
json!({ "kind": name, "clientConfigChanged": changed })
);
}
}
assert_eq!(
serde_json::to_value(crate::services::provider::SwitchResult::default()).unwrap(),
json!({ "warnings": [] })
);
}
#[tokio::test]
#[serial]
async fn route_switch_results_follow_the_applied_mode_not_the_viewed_tab() {
let _home = Home::new();
seed_settings(USER_SETTINGS);
let state = state_with(
AppType::Claude,
&[
claude("a", "https://a.example", json!({})),
claude("b", "https://b.example", json!({})),
],
"a",
)
.await;
let unchanged = fs::read(settings_path()).unwrap();
for _ in 0..2 {
let result = set_route(&state, &AppType::Claude, "b").await.unwrap();
assert_eq!(
result,
RouteSwitchResult {
kind: RouteSwitchKind::Remembered,
client_config_changed: false,
}
);
}
assert_eq!(fs::read(settings_path()).unwrap(), unchanged);
assert!(!current::is_proxy(&AppType::Claude));
enter(&state, &AppType::Claude, false).await.unwrap();
let result = set_route(&state, &AppType::Claude, "a").await.unwrap();
assert_eq!(
result,
RouteSwitchResult {
kind: RouteSwitchKind::Route,
client_config_changed: false,
}
);
enter(&state, &AppType::Claude, true).await.unwrap();
let result = set_route(&state, &AppType::Claude, "b").await.unwrap();
assert_eq!(result.kind, RouteSwitchKind::Stack);
let result = ProviderService::switch(&state, AppType::Claude, "a").unwrap();
assert_eq!(result.route.unwrap().kind, RouteSwitchKind::Stack);
// A detached proxy mode only remembers a target: the client is back on direct config.
detach_all(&state).await;
let result = set_route(&state, &AppType::Claude, "b").await.unwrap();
assert_eq!(
result,
RouteSwitchResult {
kind: RouteSwitchKind::Remembered,
client_config_changed: false,
}
);
exit(&state, &AppType::Claude).await.unwrap();
let result = ProviderService::switch(&state, AppType::Claude, "b").unwrap();
assert!(result.route.is_none());
}
#[tokio::test]
#[serial]
async fn a_route_switch_with_the_same_contract_leaves_the_client_file_alone() {
@@ -4041,9 +4157,14 @@ model_provider = "c"
ProviderService::switch(&state, AppType::Codex, "acct-a").expect("direct a again");
enter(&state, &AppType::Codex, false).await.expect("enter");
switch_route(&state, &AppType::Codex, "acct-b")
.await
.expect("route to b");
let result = ProviderService::switch(&state, AppType::Codex, "acct-b").expect("route to b");
assert_eq!(
result.route,
Some(RouteSwitchResult {
kind: RouteSwitchKind::Route,
client_config_changed: true,
})
);
assert_eq!(
account(),
json!("acct-b"),
@@ -4135,7 +4256,14 @@ model_provider = "c"
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(codex_config_path(), fs::Permissions::from_mode(0o000)).unwrap();
}
ProviderService::switch(&state, AppType::Codex, "c").expect("switch route");
let result = ProviderService::switch(&state, AppType::Codex, "c").expect("switch route");
assert_eq!(
result.route,
Some(RouteSwitchResult {
kind: RouteSwitchKind::Route,
client_config_changed: false,
})
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
@@ -4152,7 +4280,14 @@ model_provider = "c"
assert_eq!(in_use(&state, &AppType::Codex).as_deref(), Some("c"));
// 换到只有模型名不同的 d:契约变了,客户端先改写。
ProviderService::switch(&state, AppType::Codex, "d").expect("route to d");
let result = ProviderService::switch(&state, AppType::Codex, "d").expect("route to d");
assert_eq!(
result.route,
Some(RouteSwitchResult {
kind: RouteSwitchKind::Route,
client_config_changed: true,
})
);
assert_eq!(codex_doc()["model"].as_str(), Some("gpt-d"));
// 换到独有字段也不同的 a:同样改写。
ProviderService::switch(&state, AppType::Codex, "a").expect("route to a");
@@ -4783,7 +4918,14 @@ model_provider = "c"
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(gemini_env_path(), fs::Permissions::from_mode(0o000)).unwrap();
}
ProviderService::switch(&state, AppType::Gemini, "b").expect("route to b");
let result = ProviderService::switch(&state, AppType::Gemini, "b").expect("route to b");
assert_eq!(
result.route,
Some(RouteSwitchResult {
kind: RouteSwitchKind::Route,
client_config_changed: false,
})
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
@@ -4792,6 +4934,19 @@ model_provider = "c"
assert_eq!(fs::read(gemini_env_path()).unwrap(), before);
assert_eq!(in_use(&state, &AppType::Gemini).as_deref(), Some("b"));
let mut changed = state.db.get_provider_by_id("a", "gemini").unwrap().unwrap();
changed.settings_config["env"]["GEMINI_MODEL"] = json!("m-changed");
state.db.save_provider("gemini", &changed).unwrap();
let result = ProviderService::switch(&state, AppType::Gemini, "a").expect("changed model");
assert_eq!(
result.route,
Some(RouteSwitchResult {
kind: RouteSwitchKind::Route,
client_config_changed: true,
})
);
assert!(gemini_env().contains("GEMINI_MODEL=m-changed"));
exit(&state, &AppType::Gemini).await.expect("exit");
let env = gemini_env();
assert!(env.contains("GEMINI_API_KEY=key-a"), "{env}");
@@ -5069,8 +5224,18 @@ model_provider = "c"
);
// 换一家表名不同的路由:旧的代理表按写入记录删掉。
ProviderService::switch(&state, AppType::GrokBuild, "b").expect("route to b");
let result = ProviderService::switch(&state, AppType::GrokBuild, "b").expect("route to b");
assert_eq!(
result.route,
Some(RouteSwitchResult {
kind: RouteSwitchKind::Route,
client_config_changed: true,
})
);
assert_eq!(grok_tables(), vec!["b", "mine"]);
let result =
ProviderService::switch(&state, AppType::GrokBuild, "b").expect("same contract");
assert!(!result.route.unwrap().client_config_changed);
assert!(
ProviderService::switch(&state, AppType::GrokBuild, "grok-official").is_err(),
"the official account cannot be routed"
@@ -5933,6 +6098,39 @@ model_provider = "c"
assert_eq!(profile["inferenceModels"], json!(["claude-sonnet-5"]));
assert_eq!(profile["inferenceGatewayAuthScheme"], json!("bearer"));
let result = ProviderService::switch(&state, desktop.clone(), "map").expect("same profile");
assert_eq!(
result.route,
Some(RouteSwitchResult {
kind: RouteSwitchKind::Route,
client_config_changed: false,
})
);
let mut changed = state
.db
.get_provider_by_id("map", desktop.as_str())
.unwrap()
.unwrap();
for route in changed
.meta
.as_mut()
.unwrap()
.claude_desktop_model_routes
.values_mut()
{
route.label_override = Some("Updated model".to_string());
}
state.db.save_provider(desktop.as_str(), &changed).unwrap();
let result =
ProviderService::switch(&state, desktop.clone(), "map").expect("changed profile");
assert_eq!(
result.route,
Some(RouteSwitchResult {
kind: RouteSwitchKind::Route,
client_config_changed: true,
})
);
// Claude Code 退出路由时,Desktop 还在路由模式,服务不能跟着停。
enter(&state, &AppType::Claude, false).await.expect("enter");
exit(&state, &AppType::Claude).await.expect("exit");
+8 -2
View File
@@ -147,6 +147,9 @@ pub struct ProviderService;
#[serde(rename_all = "camelCase")]
pub struct SwitchResult {
pub warnings: Vec<String>,
/// Present only when this operation selected a local proxy target.
#[serde(skip_serializing_if = "Option::is_none")]
pub route: Option<crate::mode::controller::RouteSwitchResult>,
}
#[cfg(test)]
@@ -6626,12 +6629,15 @@ impl ProviderService {
.unwrap_or("(无)"),
id
);
futures::executor::block_on(crate::mode::controller::switch_route_locked(
let route = futures::executor::block_on(crate::mode::controller::switch_route_locked(
state, &app_type, _provider,
))
.map_err(|e| AppError::Message(format!("切换路由失败: {e}")))?;
// MCP 不随路由变:客户端文件没按直连重写。
return Ok(SwitchResult::default());
return Ok(SwitchResult {
route: Some(route),
..Default::default()
});
}
// Normal mode: full switch with Live config write
@@ -28,6 +28,7 @@ import {
import { useModeActions } from "@/hooks/useModeActions";
import { getRoutingReason } from "@/utils/routingReason";
import { CODEX_OFFICIAL_PROVIDER_ID } from "@/utils/providerCapabilities";
import { routeSwitchNotification } from "@/utils/routeSwitchNotification";
import { extractErrorMessage } from "@/utils/errorUtils";
import { Button } from "@/components/ui/button";
import { Notice } from "@/components/ui/notice";
@@ -214,12 +215,11 @@ export function SwitchModePanel({
const rememberDefault = async (provider: Provider) => {
try {
await proxyApi.setProxyRoute(app, provider.id);
const result = await proxyApi.setProxyRoute(app, provider.id);
await queryClient.invalidateQueries({ queryKey: ["providers", app] });
toast.success(
t("mode.toast.defaultRemembered", { provider: provider.name }),
{ closeButton: true },
);
toast.success(routeSwitchNotification(result, app, provider.name, t), {
closeButton: true,
});
} catch (error) {
toast.error(
t("mode.toast.failed", {
+5 -1
View File
@@ -25,6 +25,7 @@ import {
useSwitchProviderMutation,
} from "@/lib/query";
import { usageKeys } from "@/lib/query/usage";
import { routeSwitchNotification } from "@/utils/routeSwitchNotification";
import { extractErrorMessage } from "@/utils/errorUtils";
import { openclawKeys } from "@/hooks/useOpenClaw";
import { supportsOfficialProxyTakeover } from "@/utils/providerCapabilities";
@@ -271,7 +272,10 @@ export function useProviderActions(
messageKey = "notifications.addToConfigSuccess";
defaultMessage = "已添加到配置";
}
toast.success(t(messageKey, { defaultValue: defaultMessage }), {
const message = result?.route
? routeSwitchNotification(result.route, activeApp, provider.name, t)
: t(messageKey, { defaultValue: defaultMessage });
toast.success(message, {
closeButton: true,
});
}
+5
View File
@@ -4237,6 +4237,11 @@
"enteredStackLive": "{{app}} switched to Aggregation. The aggregated models are now in /model.",
"enteredStackDesktop": "{{app}} switched to Aggregation. Restart {{app}} to see the aggregated models in its model list.",
"exited": "Back to direct: {{app}} now connects to {{provider}} directly.",
"routeRemembered": "Provider {{provider}} saved for when the client connects through routing or aggregation.",
"routeTargetChanged": "Route target set to {{provider}}.",
"routeSwitched": "Route switched to {{provider}}. New requests will use this provider.",
"stackDefaultChanged": "Aggregation default set to {{provider}}. Selected aggregated models still use their respective providers.",
"clientConfigChanged": " Client configuration also changed. Restart {{app}} to load the updated configuration.",
"defaultRemembered": "{{provider}} will be the default provider once you switch to Aggregation.",
"restartHint": " Restart {{app}} to apply.",
"failed": "Couldn't switch: {{detail}}"
+5
View File
@@ -4237,6 +4237,11 @@
"enteredStackLive": "{{app}} を集約に切り替えました。集約したモデルが /model に表示されます。",
"enteredStackDesktop": "{{app}} を集約に切り替えました。{{app}} を再起動すると、集約したモデルがモデル一覧に表示されます。",
"exited": "直接接続に戻しました:{{app}} は {{provider}} に直接接続しています。",
"routeRemembered": "プロバイダー {{provider}} を保存しました。クライアントがルーティングまたは集約に接続すると使用されます。",
"routeTargetChanged": "ルーティング先を {{provider}} に設定しました。",
"routeSwitched": "ルーティング先を {{provider}} に切り替えました。以降の新しいリクエストはこのプロバイダーを使用します。",
"stackDefaultChanged": "集約のデフォルトを {{provider}} に設定しました。選択済みの集約モデルは引き続き各モデルのプロバイダーを使用します。",
"clientConfigChanged": "クライアント設定も更新されました。更新後の設定を読み込むには {{app}} を再起動してください。",
"defaultRemembered": "集約に切り替えると、{{provider}} が既定のプロバイダーになります。",
"restartHint": "{{app}} の再起動後に反映されます。",
"failed": "切り替えに失敗しました:{{detail}}"
+5
View File
@@ -4165,6 +4165,11 @@
"enteredStack": "{{app}}, Birleştirme moduna geçti. Birleştirilen modelleri /model içinde görmek için {{app}} uygulamasını yeniden başlatın.",
"enteredStackLive": "{{app}}, Birleştirme moduna geçti. Birleştirilen modeller artık /model içinde.",
"exited": "Direkt olarak geri dön: {{app}} artık doğrudan {{provider}}'ye bağlanıyor.",
"routeRemembered": "{{provider}} sağlayıcısı, istemci Yönlendirme veya Birleştirme üzerinden bağlandığında kullanılmak üzere kaydedildi.",
"routeTargetChanged": "Yönlendirme hedefi {{provider}} olarak ayarlandı.",
"routeSwitched": "Yönlendirme {{provider}} sağlayıcısına geçirildi. Yeni istekler bu sağlayıcıyı kullanacak.",
"stackDefaultChanged": "Birleştirme varsayılanı {{provider}} olarak ayarlandı. Seçili birleştirilmiş modeller kendi sağlayıcılarını kullanmaya devam eder.",
"clientConfigChanged": " İstemci yapılandırması da değişti. Güncel yapılandırmayı yüklemek için {{app}} uygulamasını yeniden başlatın.",
"defaultRemembered": "Birleştirme'ya geçtiğinizde {{provider}} varsayılan sağlayıcı olacaktır.",
"restartHint": "Uygulamak için {{app}}'yi yeniden başlatın.",
"failed": "Geçiş yapılamadı: {{detail}}"
+5
View File
@@ -4238,6 +4238,11 @@
"enteredStackLive": "{{app}} 已切換到聚合模式,聚合的模型已出現在 /model 裡。",
"enteredStackDesktop": "{{app}} 已切換到聚合模式。重新啟動 {{app}} 後,聚合的模型會出現在模型清單裡。",
"exited": "已回到直連:{{app}} 現在直接連線 {{provider}}。",
"routeRemembered": "已記住供應商 {{provider}},用戶端接入路由或聚合後使用。",
"routeTargetChanged": "路由目標已設為 {{provider}}。",
"routeSwitched": "路由已切換至 {{provider}},後續新請求將使用此供應商。",
"stackDefaultChanged": "聚合預設供應商已設為 {{provider}};已選聚合模型仍使用各自的供應商。",
"clientConfigChanged": "用戶端設定也已更新;重新啟動 {{app}} 以載入更新後的設定。",
"defaultRemembered": "切換到聚合模式後,預設供應商是 {{provider}}。",
"restartHint": "重新啟動 {{app}} 後生效。",
"failed": "切換失敗:{{detail}}"
+5
View File
@@ -4237,6 +4237,11 @@
"enteredStackLive": "{{app}} 已切换到聚合模式,聚合的模型已出现在 /model 里。",
"enteredStackDesktop": "{{app}} 已切换到聚合模式。重启 {{app}} 后,聚合的模型出现在模型列表里。",
"exited": "已回到直连:{{app}} 现在直接连接 {{provider}}。",
"routeRemembered": "已记住供应商 {{provider}},客户端接入路由或聚合后使用。",
"routeTargetChanged": "路由目标已设为 {{provider}}。",
"routeSwitched": "路由已切换至 {{provider}},后续新请求将使用该供应商。",
"stackDefaultChanged": "聚合默认供应商已设为 {{provider}};已选聚合模型仍使用各自的供应商。",
"clientConfigChanged": "客户端配置也已更新;重启 {{app}} 以加载更新后的配置。",
"defaultRemembered": "切换到聚合模式后,默认供应商是 {{provider}}。",
"restartHint": "重启 {{app}} 后生效。",
"failed": "切换失败:{{detail}}"
+2
View File
@@ -7,6 +7,7 @@ import type {
UniversalProvidersMap,
} from "@/types";
import type { AppId } from "./types";
import type { RouteSwitchResult } from "@/types/proxy";
export interface ProviderSortUpdate {
id: string;
@@ -20,6 +21,7 @@ export interface ProviderSwitchEvent {
export interface SwitchResult {
warnings: string[];
route?: RouteSwitchResult;
}
export interface OpenTerminalOptions {
+5 -1
View File
@@ -1,5 +1,6 @@
import { invoke } from "@tauri-apps/api/core";
import type {
RouteSwitchResult,
ProxyStatus,
ProxyServerInfo,
ProxyTakeoverStatus,
@@ -60,7 +61,10 @@ export const proxyApi = {
// 指定路由目标(聚合模式下是默认那家):直连时只记下来、下次进入路由 / 聚合模式时用它,
// 已经在路由 / 聚合模式时当场生效
async setProxyRoute(appType: string, providerId: string): Promise<void> {
async setProxyRoute(
appType: string,
providerId: string,
): Promise<RouteSwitchResult> {
return invoke("set_proxy_route", { appType, providerId });
},
+6
View File
@@ -52,6 +52,12 @@ export interface ProxyTakeoverStatus {
hermes: boolean;
}
/** Applied under the backend switch lock, not inferred from a possibly stale UI mode. */
export interface RouteSwitchResult {
kind: "remembered" | "route" | "stack";
clientConfigChanged: boolean;
}
/** 应用当前的连接方式。 */
export type AppMode = "direct" | "route" | "stack";
+55
View File
@@ -0,0 +1,55 @@
import type { TFunction } from "i18next";
import type { AppId } from "@/lib/api/types";
import type { RouteSwitchResult } from "@/types/proxy";
import { APP_DISPLAY_NAME } from "@/components/shell/AppGlyph";
/** Routing takes effect independently of client config loaded at startup. */
export function routeSwitchNotification(
result: RouteSwitchResult,
app: AppId,
provider: string,
t: TFunction,
): string {
let message: string;
switch (result.kind) {
case "remembered":
message = t("mode.toast.routeRemembered", {
provider,
defaultValue: "已记住供应商 {{provider}},客户端接入路由或聚合后使用。",
});
break;
case "route":
message = result.clientConfigChanged
? t("mode.toast.routeTargetChanged", {
provider,
defaultValue: "路由目标已设为 {{provider}}。",
})
: t("mode.toast.routeSwitched", {
provider,
defaultValue:
"路由已切换至 {{provider}},后续新请求将使用该供应商。",
});
break;
case "stack":
message = t("mode.toast.stackDefaultChanged", {
provider,
defaultValue:
"聚合默认供应商已设为 {{provider}};已选聚合模型仍使用各自的供应商。",
});
break;
}
if (
result.kind !== "remembered" &&
result.clientConfigChanged &&
(app === "codex" ||
app === "gemini" ||
app === "grokbuild" ||
app === "claude-desktop")
) {
message += t("mode.toast.clientConfigChanged", {
app: APP_DISPLAY_NAME[app],
defaultValue: "客户端配置也已更新;重启 {{app}} 以加载更新后的配置。",
});
}
return message;
}
+5 -2
View File
@@ -693,7 +693,10 @@ describe("SwitchModePanel — mode layer", () => {
const body = (await request.json()) as { providerId: string };
routeCalls.push(body);
route = body.providerId;
return HttpResponse.json(null);
return HttpResponse.json({
kind: "remembered",
clientConfigChanged: false,
});
}),
);
const takeover = captureTakeover();
@@ -717,7 +720,7 @@ describe("SwitchModePanel — mode layer", () => {
expect(onSwitch).not.toHaveBeenCalled();
await waitFor(() =>
expect(toast.success).toHaveBeenCalledWith(
"mode.toast.defaultRemembered",
"已记住供应商 kimi,客户端接入路由或聚合后使用。",
expect.anything(),
),
);
+100
View File
@@ -204,6 +204,106 @@ describe("useProviderActions", () => {
);
});
it.each(["codex", "gemini", "grokbuild", "claude-desktop"] as const)(
"reports a %s route switch without a restart when the client config is unchanged",
async (app) => {
switchProviderMutateAsync.mockResolvedValueOnce({
warnings: [],
route: { kind: "route", clientConfigChanged: false },
});
const { wrapper } = createWrapper();
const { result } = renderHook(() => useProviderActions(app, true), {
wrapper,
});
await act(async () => {
await result.current.switchProvider(
createProvider({ category: "custom" }),
);
});
expect(toastSuccessMock).toHaveBeenCalledWith(
"路由已切换至 Test Provider,后续新请求将使用该供应商。",
{ closeButton: true },
);
},
);
it("keeps a client reload hint when a routed switch rewrites Codex configuration", async () => {
switchProviderMutateAsync.mockResolvedValueOnce({
warnings: [],
route: { kind: "route", clientConfigChanged: true },
});
const { wrapper } = createWrapper();
const { result } = renderHook(() => useProviderActions("codex", true), {
wrapper,
});
await act(async () => {
await result.current.switchProvider(
createProvider({ category: "custom" }),
);
});
expect(toastSuccessMock).toHaveBeenCalledWith(
"路由目标已设为 Test Provider。客户端配置也已更新;重启 Codex 以加载更新后的配置。",
{ closeButton: true },
);
});
it("describes the aggregation default without claiming to reroute selected aggregated models", async () => {
switchProviderMutateAsync.mockResolvedValueOnce({
warnings: [],
route: { kind: "stack", clientConfigChanged: false },
});
const { wrapper } = createWrapper();
const { result } = renderHook(() => useProviderActions("codex", true), {
wrapper,
});
await act(async () => {
await result.current.switchProvider(
createProvider({ category: "custom" }),
);
});
expect(toastSuccessMock).toHaveBeenCalledWith(
"聚合默认供应商已设为 Test Provider;已选聚合模型仍使用各自的供应商。",
{ closeButton: true },
);
});
it("uses the operation result even if the UI takeover status is stale", async () => {
switchProviderMutateAsync.mockResolvedValueOnce({
warnings: [],
route: { kind: "route", clientConfigChanged: false },
});
const { wrapper } = createWrapper();
const { result } = renderHook(() => useProviderActions("codex", false), {
wrapper,
});
await act(async () => {
await result.current.switchProvider(
createProvider({ category: "custom" }),
);
});
expect(toastSuccessMock).toHaveBeenCalledWith(
"路由已切换至 Test Provider,后续新请求将使用该供应商。",
{ closeButton: true },
);
});
it("does not infer a hot switch solely from the UI takeover flag", async () => {
switchProviderMutateAsync.mockResolvedValueOnce({ warnings: [] });
const { wrapper } = createWrapper();
const { result } = renderHook(() => useProviderActions("codex", true), {
wrapper,
});
await act(async () => {
await result.current.switchProvider(
createProvider({ category: "custom" }),
);
});
expect(toastSuccessMock).toHaveBeenCalledWith(
"切换成功,请重启客户端以生效",
{ closeButton: true },
);
});
it("asks for a Gemini CLI restart after switching, since it reads .env only at startup", async () => {
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
const { wrapper } = createWrapper();
@@ -0,0 +1,40 @@
import type { ReactNode } from "react";
import { act, renderHook } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
import { describe, expect, it } from "vitest";
import { useSwitchProviderMutation } from "@/lib/query/mutations";
import { server } from "../msw/server";
import { createTestQueryClient } from "../utils/testQueryClient";
// Exercise the actual invoke -> mutation -> caller path: the result must not be
// swallowed by onSuccess/cache invalidation before notification selection.
describe("provider switch result transport", () => {
it.each(["route", "stack", "remembered"] as const)(
"preserves the applied %s result and warnings",
async (kind) => {
const expected = {
warnings: ["codex_auth_cleanup_failed: test"],
route: { kind, clientConfigChanged: kind === "stack" },
};
server.use(
http.post("http://tauri.local/switch_provider", () =>
HttpResponse.json(expected),
),
);
const client = createTestQueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useSwitchProviderMutation("codex"), {
wrapper,
});
let actual;
await act(async () => {
actual = await result.current.mutateAsync("provider-b");
});
expect(actual).toEqual(expected);
client.clear();
},
);
});
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import { createInstance } from "i18next";
import { routeSwitchNotification } from "@/utils/routeSwitchNotification";
import type { RouteSwitchResult } from "@/types/proxy";
import type { AppId } from "@/lib/api/types";
import en from "@/i18n/locales/en.json";
import zh from "@/i18n/locales/zh.json";
import tw from "@/i18n/locales/zh-TW.json";
import ja from "@/i18n/locales/ja.json";
import tr from "@/i18n/locales/tr.json";
async function translate(locale = "zh") {
const i18n = createInstance();
await i18n.init({
lng: locale,
resources: {
en: { translation: en },
zh: { translation: zh },
"zh-TW": { translation: tw },
ja: { translation: ja },
tr: { translation: tr },
},
interpolation: { escapeValue: false },
});
return i18n.t;
}
describe("route switch notifications", () => {
it.each([
"codex",
"gemini",
"grokbuild",
"claude",
"claude-desktop",
] as const)(
"does not require restarting %s when only the upstream changes",
async (app) => {
const t = await translate();
expect(
routeSwitchNotification(
{ kind: "route", clientConfigChanged: false },
app,
"B",
t,
),
).toBe("路由已切换至 B,后续新请求将使用该供应商。");
},
);
it.each(["codex", "gemini", "grokbuild", "claude-desktop"] as const)(
"retains the %s reload hint for a changed route or aggregation contract",
async (app) => {
const t = await translate();
for (const kind of ["route", "stack"] as const) {
expect(
routeSwitchNotification(
{ kind, clientConfigChanged: true },
app,
"B",
t,
),
).toContain("客户端配置也已更新;重启");
}
},
);
it("does not promise immediate use of changed client-side credentials", async () => {
const t = await translate();
expect(
routeSwitchNotification(
{ kind: "route", clientConfigChanged: true },
"codex",
"Official B",
t,
),
).toBe(
"路由目标已设为 Official B。客户端配置也已更新;重启 Codex 以加载更新后的配置。",
);
});
it("does not request a restart for Claude Code's live config reload", async () => {
const t = await translate();
expect(
routeSwitchNotification(
{ kind: "stack", clientConfigChanged: true },
"claude",
"B",
t,
),
).toBe("聚合默认供应商已设为 B;已选聚合模型仍使用各自的供应商。");
});
it("does not claim that a remembered choice is already routing", async () => {
const t = await translate();
expect(
routeSwitchNotification(
{ kind: "remembered", clientConfigChanged: false },
"codex",
"B",
t,
),
).toBe("已记住供应商 B,客户端接入路由或聚合后使用。");
});
it.each(["en", "zh", "zh-TW", "ja", "tr"])(
"has complete translations and interpolation for %s",
async (locale) => {
const t = await translate(locale);
for (const kind of [
"remembered",
"route",
"stack",
] as RouteSwitchResult["kind"][]) {
const message = routeSwitchNotification(
{ kind, clientConfigChanged: kind !== "remembered" },
"codex" as AppId,
"Provider B",
t,
);
expect(message).toContain("Provider B");
expect(message).not.toMatch(/mode\.toast|\{\{|undefined/);
if (kind !== "remembered") expect(message).toContain("Codex");
}
},
);
});