Merge pull request #119 from router-for-me/dev

Dev
This commit is contained in:
lzt404 authored and GitHub committed 2026-08-11 18:52:48 +08:00
commit 1b9aec1dac
15 files changed
+380 -110

No files matched your search

+1 -1
View File
@@ -1 +1 @@
7.2.127
7.2.128
+1 -1
View File
@@ -593,7 +593,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
[[package]]
name = "cpa-gui"
version = "0.2.17"
version = "0.2.19"
dependencies = [
"base64 0.22.1",
"chrono",
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "cpa-gui"
# Single source of truth for the desktop app version.
version = "0.2.17"
version = "0.2.19"
description = "CPA desktop management shell"
authors = ["lzt404"]
edition = "2021"
+75 -8
View File
@@ -64,6 +64,79 @@ pub(crate) async fn put_management_config_yaml(
read_management_value(response).await.map(|_| ())
}
pub(crate) async fn put_management_oauth_model_aliases(
config: &GuiConfigFile,
aliases: &serde_json::Value,
) -> Result<(), String> {
let client = management_http_client()?;
let response = client
.put(management_endpoint(config, "oauth-model-alias")?)
.header("Authorization", management_authorization(config)?)
.json(aliases)
.send()
.await
.map_err(|error| format!("保存 OAuth 模型别名失败: {error}"))?;
read_management_value(response).await.map(|_| ())
}
#[derive(Debug, PartialEq)]
pub(crate) struct ManagementAliasConfigChanges {
pub(crate) oauth_model_aliases: Option<serde_json::Value>,
pub(crate) update_config_yaml: bool,
}
pub(crate) fn management_alias_config_changes(
current: &str,
updated: &str,
) -> Result<ManagementAliasConfigChanges, String> {
let split = |content: &str| {
let mut document = serde_norway::from_str::<serde_norway::Value>(content)
.map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?;
let root = document
.as_mapping_mut()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
let oauth_aliases = root
.remove(yaml_key("oauth-model-alias"))
.unwrap_or_else(|| serde_norway::Value::Mapping(serde_norway::Mapping::new()));
Ok::<_, String>((document, oauth_aliases))
};
let (current_without_oauth, current_oauth) = split(current)?;
let (updated_without_oauth, updated_oauth) = split(updated)?;
let oauth_model_aliases = if current_oauth == updated_oauth {
None
} else {
Some(
serde_json::to_value(updated_oauth)
.map_err(|error| format!("序列化 OAuth 模型别名失败: {error}"))?,
)
};
Ok(ManagementAliasConfigChanges {
oauth_model_aliases,
update_config_yaml: current_without_oauth != updated_without_oauth,
})
}
pub(crate) async fn put_management_alias_config_changes(
config: &GuiConfigFile,
current: &str,
updated: &str,
) -> Result<(), String> {
if updated == current {
return Ok(());
}
let changes = management_alias_config_changes(current, updated)?;
if let Some(oauth_model_aliases) = changes.oauth_model_aliases.as_ref() {
// The dedicated endpoint refreshes CPA's OAuth model registry immediately.
// Writing the same section only through config.yaml updates the file and
// management snapshot, but can leave /v1/models and routing stale.
put_management_oauth_model_aliases(config, oauth_model_aliases).await?;
}
if changes.update_config_yaml {
put_management_config_yaml(config, updated).await?;
}
Ok(())
}
pub(crate) async fn ensure_claude_desktop_model_aliases(
config: &GuiConfigFile,
mappings: &ClaudeDesktopModelMappings,
@@ -84,10 +157,7 @@ pub(crate) async fn ensure_claude_desktop_model_aliases(
)?
}
};
if updated != content {
put_management_config_yaml(config, &updated).await?;
}
Ok(())
put_management_alias_config_changes(config, &content, &updated).await
}
pub(crate) async fn remove_managed_claude_model_aliases(
@@ -95,10 +165,7 @@ pub(crate) async fn remove_managed_claude_model_aliases(
) -> Result<(), String> {
let content = fetch_management_config_yaml(config).await?;
let updated = remove_managed_claude_model_aliases_in_yaml(&content)?;
if updated != content {
put_management_config_yaml(config, &updated).await?;
}
Ok(())
put_management_alias_config_changes(config, &content, &updated).await
}
pub(crate) fn ensure_claude_desktop_model_aliases_in_yaml(
+1 -1
View File
@@ -146,7 +146,7 @@ const CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW: u64 = 1_000_000;
const CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV: &str = "CLAUDE_CODE_MAX_CONTEXT_TOKENS";
const CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV: &str = "CLAUDE_AUTOCOMPACT_PCT_OVERRIDE";
const DISABLE_AUTO_COMPACT_ENV: &str = "DISABLE_AUTO_COMPACT";
const DEFAULT_CLAUDE_AUTO_COMPACT_PCT: u8 = 100;
const DEFAULT_CLAUDE_AUTO_COMPACT_PCT: u8 = 90;
const MODEL_ALIAS_CONFIG_SECTIONS: &[&str] = &[
"codex-api-key",
"openai-compatibility",
+34 -3
View File
@@ -23,7 +23,7 @@ fn claude_agent_config_preserves_existing_fields() {
assert_eq!(value["env"]["ANTHROPIC_AUTH_TOKEN"], DEFAULT_API_KEY);
assert_eq!(value["env"]["ANTHROPIC_MODEL"], "claude-test");
assert_eq!(value["env"][CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV], "200000");
assert_eq!(value["env"][CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV], "100");
assert_eq!(value["env"][CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV], "90");
assert!(value["env"].get(DISABLE_AUTO_COMPACT_ENV).is_none());
assert!(value["env"].get("CLAUDE_CODE_EFFORT_LEVEL").is_none());
assert_eq!(value["env"]["CLAUDE_CODE_SUBAGENT_MODEL"], "claude-test");
@@ -66,7 +66,7 @@ fn claude_code_inspection_normalizes_legacy_1m_suffix() {
assert!(mappings.sonnet_1m);
assert!(!mappings.haiku_1m);
assert_eq!(mappings.max_context_tokens, 1_000_000);
assert_eq!(mappings.auto_compact_pct, 100);
assert_eq!(mappings.auto_compact_pct, 90);
assert!(!mappings.disable_auto_compact);
fs::remove_dir_all(directory).unwrap();
}
@@ -80,7 +80,7 @@ fn claude_mapping_legacy_json_defaults_1m_preferences_off() {
assert!(!mappings.sonnet_1m);
assert!(!mappings.haiku_1m);
assert_eq!(mappings.max_context_tokens, 200_000);
assert_eq!(mappings.auto_compact_pct, 100);
assert_eq!(mappings.auto_compact_pct, 90);
assert!(!mappings.disable_auto_compact);
}
@@ -905,6 +905,37 @@ fn claude_desktop_aliases_support_codex_oauth_models() {
);
}
#[test]
fn claude_oauth_alias_changes_use_the_runtime_refresh_endpoint() {
let current = "port: 8317\nopenai-compatibility: []\n";
let oauth_only = "port: 8317\nopenai-compatibility: []\noauth-model-alias:\n codex:\n - name: gpt-5.6-sol\n alias: claude-opus-5\n fork: true\n";
let changes = management_alias_config_changes(current, oauth_only).unwrap();
assert!(!changes.update_config_yaml);
assert_eq!(
changes.oauth_model_aliases,
Some(serde_json::json!({
"codex": [{
"name": "gpt-5.6-sol",
"alias": "claude-opus-5",
"fork": true,
}]
}))
);
let mixed = oauth_only.replace(
"openai-compatibility: []",
"openai-compatibility:\n - name: CPA\n base-url: https://example.com/v1",
);
let changes = management_alias_config_changes(current, &mixed).unwrap();
assert!(changes.update_config_yaml);
assert!(changes.oauth_model_aliases.is_some());
let changes = management_alias_config_changes(oauth_only, current).unwrap();
assert!(!changes.update_config_yaml);
assert_eq!(changes.oauth_model_aliases, Some(serde_json::json!({})));
}
#[test]
fn claude_managed_aliases_expand_flow_yaml_and_are_removed_selectively() {
let compact = "# keep this comment\ncodex-api-key: [{api-key: test, models: [{name: gpt-5.6-sol, alias: ''}]}]\ncredential-concurrency: {max-limit: 1000000, cleanup-interval: 5s}\n";
+1 -1
View File
@@ -576,7 +576,7 @@ export const jaOverrides = {
'agents.claudeCodeRuntime.maxContextTokensHint': 'Claude Code の既定値は 200000 です。上流モデルの上限を超えないでください。',
'agents.claudeCodeRuntime.context1mHint': 'このマッピングで [1m] モデルを使用し、最大コンテキストを 1000000 にします。3 つすべてを無効にすると 200000 に戻ります。',
'agents.claudeCodeRuntime.autoCompactPct': '自動圧縮の開始率',
'agents.claudeCodeRuntime.autoCompactPctHint': '既定値は 100% です。小さい値ほど早く圧縮します。',
'agents.claudeCodeRuntime.autoCompactPctHint': '既定値は 90% です。小さい値ほど早く圧縮します。',
'agents.claudeCodeRuntime.disableAutoCompact': '自動圧縮を無効化',
'agents.claudeCodeRuntime.disableAutoCompactHint': '有効時は /compact による手動圧縮のみ利用できます。',
'agents.claudeDesktopMapping.customMapping': 'モデルエイリアスを使用',
+1 -1
View File
@@ -571,7 +571,7 @@ export const en: Record<MessageKey, string> = {
'agents.claudeCodeRuntime.maxContextTokensHint': 'Claude Code defaults to 200000. Do not exceed the upstream model limit.',
'agents.claudeCodeRuntime.context1mHint': 'Uses the [1m] model for this mapping and sets the maximum context to 1000000. It returns to 200000 after all three mappings are off.',
'agents.claudeCodeRuntime.autoCompactPct': 'Auto-compact trigger',
'agents.claudeCodeRuntime.autoCompactPctHint': 'Defaults to 100%. Lower values compact earlier.',
'agents.claudeCodeRuntime.autoCompactPctHint': 'Defaults to 90%. Lower values compact earlier.',
'agents.claudeCodeRuntime.disableAutoCompact': 'Disable auto-compaction',
'agents.claudeCodeRuntime.disableAutoCompactHint': 'Only manual /compact remains available when enabled.',
'agents.claudeDesktopMapping.customMapping': 'Use Model Aliases',
+1 -1
View File
@@ -569,7 +569,7 @@ export const zhCN = {
'agents.claudeCodeRuntime.maxContextTokensHint': 'Claude Code 默认 200000;请勿超过上游模型实际支持的窗口。',
'agents.claudeCodeRuntime.context1mHint': '为此映射使用 [1m] 模型并将最大窗口设为 1000000;三个映射均关闭后恢复为 200000。',
'agents.claudeCodeRuntime.autoCompactPct': '触发压缩百分比',
'agents.claudeCodeRuntime.autoCompactPctHint': '默认 100;调低后会更早自动压缩。',
'agents.claudeCodeRuntime.autoCompactPctHint': '默认 90%;调低后会更早自动压缩。',
'agents.claudeCodeRuntime.disableAutoCompact': '禁止自动压缩',
'agents.claudeCodeRuntime.disableAutoCompactHint': '开启后仅允许用户通过 /compact 手动压缩。',
'agents.claudeDesktopMapping.customMapping': '使用模型别名',
+135 -46
View File
@@ -39,6 +39,7 @@ import {
} from '../services/agentModelPicker';
import {
resolveAgentConfigurationAction,
resolveAgentModelMappingsDraftSourceForClient,
sameAgentModel,
sameAgentModelMappings,
} from '../services/agentConfigurationDraft';
@@ -55,6 +56,8 @@ type AgentClientId =
| 'hermes'
| 'pi';
type ClaudeModelMappingClientId = 'claude-code' | 'claude-desktop';
type AgentModificationState = 'unconfigured' | 'applied' | 'invalid';
type AgentConfigStatus = {
@@ -111,7 +114,7 @@ type ClaudeModelMappings = {
const CODEX_OAUTH_LOGIN_REQUIRED_ERROR = 'CODEX_OAUTH_LOGIN_REQUIRED';
const DEFAULT_CLAUDE_CODE_MAX_CONTEXT_TOKENS = 200_000;
const DEFAULT_CLAUDE_AUTO_COMPACT_PCT = 100;
const DEFAULT_CLAUDE_AUTO_COMPACT_PCT = 90;
const createClaudeModelMappings = (model: string): ClaudeModelMappings => ({
opus: model,
@@ -125,6 +128,23 @@ const createClaudeModelMappings = (model: string): ClaudeModelMappings => ({
disableAutoCompact: false,
});
const createClaudeModelMappingsByClient = (): Record<
ClaudeModelMappingClientId,
ClaudeModelMappings
> => ({
'claude-code': createClaudeModelMappings(''),
'claude-desktop': createClaudeModelMappings(''),
});
const createClaudeBooleanByClient = (): Record<ClaudeModelMappingClientId, boolean> => ({
'claude-code': false,
'claude-desktop': false,
});
let claudeModelMappingsDraftCache = createClaudeModelMappingsByClient();
let claudeCustomMappingCache = createClaudeBooleanByClient();
const claudeModelMappingsDirtyCache = createClaudeBooleanByClient();
const claudeMappingRoles = [
{
key: 'opus',
@@ -547,10 +567,15 @@ export function AgentsPage() {
const [modelByClient, setModelByClient] = useState<Partial<Record<AgentClientId, string>>>(
readAgentModelSelections,
);
const [claudeModelMappingsDraft, setClaudeModelMappingsDraft] = useState<ClaudeModelMappings>(
createClaudeModelMappings(''),
const [claudeModelMappingsDraftByClient, setClaudeModelMappingsDraftByClientState] = useState(
() => ({
'claude-code': { ...claudeModelMappingsDraftCache['claude-code'] },
'claude-desktop': { ...claudeModelMappingsDraftCache['claude-desktop'] },
}),
);
const [claudeCustomMappingByClient, setClaudeCustomMappingByClientState] = useState(
() => ({ ...claudeCustomMappingCache }),
);
const [claudeCustomMapping, setClaudeCustomMapping] = useState(false);
const [loading, setLoading] = useState(true);
const [modelLoading, setModelLoading] = useState(false);
const [busyAction, setBusyAction] = useState<
@@ -571,7 +596,31 @@ export function AgentsPage() {
const [piProviderUpdateStatus, setPiProviderUpdateStatus] = useState<PiProviderUpdateStatus | null>(null);
const modelRequestRef = useRef(0);
const piUpdateRequestRef = useRef(0);
const claudeModelMappingsDirtyRef = useRef(false);
const claudeModelMappingsDirtyRef = useRef(claudeModelMappingsDirtyCache);
const setClaudeModelMappingsDraftByClient = useCallback((
update: (
current: Record<ClaudeModelMappingClientId, ClaudeModelMappings>,
) => Record<ClaudeModelMappingClientId, ClaudeModelMappings>,
) => {
setClaudeModelMappingsDraftByClientState((current) => {
const next = update(current);
claudeModelMappingsDraftCache = next;
return next;
});
}, []);
const setClaudeCustomMappingByClient = useCallback((
update: (
current: Record<ClaudeModelMappingClientId, boolean>,
) => Record<ClaudeModelMappingClientId, boolean>,
) => {
setClaudeCustomMappingByClientState((current) => {
const next = update(current);
claudeCustomMappingCache = next;
return next;
});
}, []);
const loadStatuses = useCallback(async (forceRefresh = false) => {
const command = forceRefresh
@@ -667,7 +716,8 @@ export function AgentsPage() {
setClearConfirmOpen(false);
setOauthLoginRequiredAction(null);
setOauthConfigurationDraft(null);
claudeModelMappingsDirtyRef.current = false;
// Preserve each Claude client's unsaved draft while navigating between clients.
// Its dirty flag is cleared only after a successful apply, close, or reset action.
}, [selected]);
const activeDefinition = agentDefinitions.find((agent) => agent.id === selected)
@@ -681,6 +731,12 @@ export function AgentsPage() {
const selectedModel = selectedModelOption?.name ?? '';
const isPiClient = selected === 'pi';
const isClaudeModelMappingClient = selected === 'claude-code' || selected === 'claude-desktop';
const claudeModelMappingsDraft = isClaudeModelMappingClient
? claudeModelMappingsDraftByClient[selected]
: createClaudeModelMappings('');
const claudeCustomMapping = isClaudeModelMappingClient
? claudeCustomMappingByClient[selected]
: false;
const claudeMappingModels = useMemo(
() => filterAgentModelsByAlias(models, claudeCustomMapping),
[claudeCustomMapping, models],
@@ -720,18 +776,28 @@ export function AgentsPage() {
const appliedMappings = selected === 'claude-code'
? activeStatus?.claudeCodeModelMappings
: activeStatus?.claudeDesktopModelMappings;
if (!claudeModelMappingsDirtyRef.current && appliedMappings) {
const appliedModels = claudeMappingRoles
.map((role) => findAgentModel(models, appliedMappings[role.key]))
.filter((model): model is ModelOption => model !== null);
if (appliedModels.length === claudeMappingRoles.length) {
setClaudeCustomMapping(appliedModels.every((model) => Boolean(model.isAlias)));
}
const dirty = claudeModelMappingsDirtyRef.current[selected];
if (!dirty) {
const appliedModels = appliedMappings
? claudeMappingRoles
.map((role) => findAgentModel(models, appliedMappings[role.key]))
.filter((model): model is ModelOption => model !== null)
: [];
setClaudeCustomMappingByClient((current) => ({
...current,
[selected]: appliedModels.length === claudeMappingRoles.length
&& appliedModels.every((model) => Boolean(model.isAlias)),
}));
}
setClaudeModelMappingsDraft((current) => {
const source = claudeModelMappingsDirtyRef.current
? current
: appliedMappings ?? current;
setClaudeModelMappingsDraftByClient((current) => {
const currentClientDraft = current[selected];
const source = resolveAgentModelMappingsDraftSourceForClient(
current,
selected,
appliedMappings,
createClaudeModelMappings(selectedModel),
dirty,
);
const next: ClaudeModelMappings = {
opus: findAgentModel(models, source.opus)?.name ?? selectedModel,
sonnet: findAgentModel(models, source.sonnet)?.name ?? selectedModel,
@@ -743,7 +809,9 @@ export function AgentsPage() {
autoCompactPct: source.autoCompactPct ?? DEFAULT_CLAUDE_AUTO_COMPACT_PCT,
disableAutoCompact: Boolean(source.disableAutoCompact),
};
return sameAgentModelMappings(current, next) ? current : next;
return sameAgentModelMappings(currentClientDraft, next)
? current
: { ...current, [selected]: next };
});
}, [
activeStatus?.claudeCodeModelMappings,
@@ -842,26 +910,30 @@ export function AgentsPage() {
value: string,
) => {
const model = findAgentModel(models, value);
if (!model) return;
claudeModelMappingsDirtyRef.current = true;
if (!model || !isClaudeModelMappingClient) return;
claudeModelMappingsDirtyRef.current[selected] = true;
setModelSelectionError('');
setClaudeModelMappingsDraft((current) => ({ ...current, [role]: model.name }));
setClaudeModelMappingsDraftByClient((current) => ({
...current,
[selected]: { ...current[selected], [role]: model.name },
}));
};
const changeClaude1mPreference = (
preference: 'opus1m' | 'sonnet1m' | 'haiku1m',
enabled: boolean,
) => {
claudeModelMappingsDirtyRef.current = true;
setClaudeModelMappingsDraft((current) => {
const next = { ...current, [preference]: enabled };
if (!isClaudeModelMappingClient) return;
claudeModelMappingsDirtyRef.current[selected] = true;
setClaudeModelMappingsDraftByClient((current) => {
const next = { ...current[selected], [preference]: enabled };
if (selected === 'claude-code') {
const any1mEnabled = next.opus1m || next.sonnet1m || next.haiku1m;
next.maxContextTokens = any1mEnabled
? 1_000_000
: DEFAULT_CLAUDE_CODE_MAX_CONTEXT_TOKENS;
}
return next;
return { ...current, [selected]: next };
});
};
@@ -869,37 +941,47 @@ export function AgentsPage() {
key: 'maxContextTokens' | 'autoCompactPct',
value: number,
) => {
claudeModelMappingsDirtyRef.current = true;
setClaudeModelMappingsDraft((current) => ({ ...current, [key]: value }));
if (selected !== 'claude-code') return;
claudeModelMappingsDirtyRef.current[selected] = true;
setClaudeModelMappingsDraftByClient((current) => ({
...current,
[selected]: { ...current[selected], [key]: value },
}));
};
const changeClaudeCodeAutoCompactDisabled = (disabled: boolean) => {
claudeModelMappingsDirtyRef.current = true;
setClaudeModelMappingsDraft((current) => ({
if (selected !== 'claude-code') return;
claudeModelMappingsDirtyRef.current[selected] = true;
setClaudeModelMappingsDraftByClient((current) => ({
...current,
disableAutoCompact: disabled,
[selected]: {
...current[selected],
disableAutoCompact: disabled,
},
}));
};
const changeClaudeCustomMapping = (enabled: boolean) => {
setClaudeCustomMapping(enabled);
if (!isClaudeModelMappingClient) return;
setClaudeCustomMappingByClient((current) => ({ ...current, [selected]: enabled }));
setModelSelectionError('');
setClaudeModelMappingsDraft((current) => {
setClaudeModelMappingsDraftByClient((current) => {
const currentClientDraft = current[selected];
const next: ClaudeModelMappings = {
opus: resolveAgentModelForAliasMode(models, current.opus, enabled),
sonnet: resolveAgentModelForAliasMode(models, current.sonnet, enabled),
haiku: resolveAgentModelForAliasMode(models, current.haiku, enabled),
opus1m: current.opus1m,
sonnet1m: current.sonnet1m,
haiku1m: current.haiku1m,
maxContextTokens: current.maxContextTokens,
autoCompactPct: current.autoCompactPct,
disableAutoCompact: current.disableAutoCompact,
opus: resolveAgentModelForAliasMode(models, currentClientDraft.opus, enabled),
sonnet: resolveAgentModelForAliasMode(models, currentClientDraft.sonnet, enabled),
haiku: resolveAgentModelForAliasMode(models, currentClientDraft.haiku, enabled),
opus1m: currentClientDraft.opus1m,
sonnet1m: currentClientDraft.sonnet1m,
haiku1m: currentClientDraft.haiku1m,
maxContextTokens: currentClientDraft.maxContextTokens,
autoCompactPct: currentClientDraft.autoCompactPct,
disableAutoCompact: currentClientDraft.disableAutoCompact,
};
if (!sameAgentModelMappings(current, next)) {
claudeModelMappingsDirtyRef.current = true;
if (!sameAgentModelMappings(currentClientDraft, next)) {
claudeModelMappingsDirtyRef.current[selected] = true;
}
return next;
return { ...current, [selected]: next };
});
};
@@ -990,9 +1072,11 @@ export function AgentsPage() {
claudeCodeModelMappings: selected === 'claude-code' ? claudeModelMappings : null,
claudeDesktopModelMappings: selected === 'claude-desktop' ? claudeModelMappings : null,
});
if (isClaudeModelMappingClient) {
claudeModelMappingsDirtyRef.current[selected] = false;
}
await reloadStatusesAfterAction();
setOauthConfigurationDraft(null);
claudeModelMappingsDirtyRef.current = false;
} catch (requestError) {
if (!handleOAuthLoginError(requestError, 'apply')) {
setConfigurationError(String(requestError));
@@ -1068,6 +1152,9 @@ export function AgentsPage() {
setBusyAction('close-config');
try {
await invoke<AgentConfigActionResult>('close_agent_config_modification', { client: selected });
if (isClaudeModelMappingClient) {
claudeModelMappingsDirtyRef.current[selected] = false;
}
await reloadStatusesAfterAction();
setOauthConfigurationDraft(null);
} catch (requestError) {
@@ -1095,9 +1182,11 @@ export function AgentsPage() {
claudeDesktopModelMappings: selected === 'claude-desktop' ? claudeModelMappings : null,
});
setDefaultConfirmOpen(false);
if (isClaudeModelMappingClient) {
claudeModelMappingsDirtyRef.current[selected] = false;
}
await reloadStatusesAfterAction();
setOauthConfigurationDraft(null);
claudeModelMappingsDirtyRef.current = false;
} catch (requestError) {
if (!handleOAuthLoginError(requestError, 'apply')) {
setDefaultError(String(requestError));
+22 -45
View File
@@ -20,6 +20,10 @@ import {
type AuthFileSnapshot,
} from '../services/authFiles';
import { managementApi, responseList } from '../services/managementApi';
import {
createOAuthLoginSuccessCache,
shouldShowOAuthLoginStatus,
} from '../services/oauthLoginState';
import { AuthFileManagementPage } from './AuthFileManagementPage';
import { QuotaPage } from './QuotaPage';
@@ -65,8 +69,15 @@ const OAUTH_CALLBACK_SUPPORTED = new Set<OAuthProviderId>([
'xai',
]);
const XAI_CALLBACK_URL = 'http://127.0.0.1:56121/callback';
const OAUTH_SUCCESS_RESET_MS = 5000;
const OAUTH_POLL_INTERVAL_MS = 3000;
const oauthLoginSuccessCache = createOAuthLoginSuccessCache<OAuthProviderId>();
const cachedOAuthProviderStates = (): Partial<Record<OAuthProviderId, OAuthProviderState>> => (
oauthLoginSuccessCache.snapshot().reduce<Partial<Record<OAuthProviderId, OAuthProviderState>>>(
(states, provider) => ({ ...states, [provider]: { status: 'success' } }),
{},
)
);
export function OAuthManagementPage() {
const { t } = useI18n();
@@ -115,14 +126,15 @@ export function OAuthManagementPage() {
export function OAuthLoginPage() {
const { t } = useI18n();
const [states, setStates] = useState<Partial<Record<OAuthProviderId, OAuthProviderState>>>({});
const [states, setStates] = useState<Partial<Record<OAuthProviderId, OAuthProviderState>>>(
cachedOAuthProviderStates,
);
const [notice, setNotice] = useState<{
message: string;
tone: 'success' | 'error' | 'info';
} | null>(null);
const pollingTimers = useRef<Partial<Record<OAuthProviderId, number>>>({});
const pollingRequests = useRef<Partial<Record<OAuthProviderId, boolean>>>({});
const successResetTimers = useRef<Partial<Record<OAuthProviderId, number>>>({});
const credentialSnapshots = useRef<Partial<Record<OAuthProviderId, AuthFileSnapshot>>>({});
const noticeTimerRef = useRef<number | null>(null);
@@ -156,32 +168,10 @@ export function OAuthLoginPage() {
delete pollingRequests.current[provider];
}, []);
const clearSuccessResetTimer = useCallback((provider: OAuthProviderId) => {
const timer = successResetTimers.current[provider];
if (timer !== undefined) window.clearTimeout(timer);
delete successResetTimers.current[provider];
}, []);
const clearProviderTimers = useCallback(
(provider: OAuthProviderId) => {
clearPollingTimer(provider);
clearSuccessResetTimer(provider);
},
[clearPollingTimer, clearSuccessResetTimer],
);
const resetProviderAttempt = useCallback(
(provider: OAuthProviderId) => {
clearProviderTimers(provider);
delete credentialSnapshots.current[provider];
setStates((current) => ({ ...current, [provider]: { status: 'idle' } }));
},
[clearProviderTimers],
);
const completeProviderAuth = useCallback(
(provider: OAuthProviderId) => {
clearProviderTimers(provider);
clearPollingTimer(provider);
oauthLoginSuccessCache.mark(provider);
updateProviderState(provider, {
url: undefined,
state: undefined,
@@ -193,11 +183,8 @@ export function OAuthLoginPage() {
callbackStatus: undefined,
callbackError: undefined,
});
successResetTimers.current[provider] = window.setTimeout(() => {
resetProviderAttempt(provider);
}, OAUTH_SUCCESS_RESET_MS);
},
[clearProviderTimers, resetProviderAttempt, updateProviderState],
[clearPollingTimer, updateProviderState],
);
const captureCredentialSnapshot = useCallback(async (provider: OAuthProviderId) => {
@@ -281,15 +268,12 @@ export function OAuthLoginPage() {
Object.values(pollingTimers.current).forEach((timer) => {
if (timer !== undefined) window.clearInterval(timer);
});
Object.values(successResetTimers.current).forEach((timer) => {
if (timer !== undefined) window.clearTimeout(timer);
});
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
};
}, []);
const startLogin = async (provider: OAuthProviderId) => {
clearProviderTimers(provider);
clearPollingTimer(provider);
updateProviderState(provider, {
url: undefined,
state: undefined,
@@ -434,9 +418,9 @@ export function OAuthLoginPage() {
<img src={provider.icon} alt="" className="provider-logo" />
<div>
<h2>{provider.name}</h2>
<span className={`state-pill ${state.status === 'success' ? 'success' : state.status === 'error' ? 'error' : ''}`}>
{oauthStatusLabel(state, t)}
</span>
{shouldShowOAuthLoginStatus(state.status) ? (
<span className="state-pill success">{t('oauth.status.completed')}</span>
) : null}
</div>
</div>
@@ -510,13 +494,6 @@ export function OAuthLoginPage() {
);
}
function oauthStatusLabel(state: OAuthProviderState, t: ReturnType<typeof useI18n>['t']) {
if (state.status === 'success') return t('oauth.status.completed');
if (state.status === 'error') return t('oauth.status.failed');
if (state.status === 'waiting' || state.polling) return t('oauth.status.waiting');
return t('oauth.status.signedOut');
}
function providerLabel(provider: OAuthProviderId) {
return oauthProviders.find((item) => item.id === provider)?.name ?? provider;
}
+23 -1
View File
@@ -11,6 +11,8 @@ export type AgentConfigurationModificationState = 'unconfigured' | 'applied' | '
export type AgentConfigurationAction = 'apply' | 'update' | 'close';
export type ClaudeModelMappingClientId = 'claude-code' | 'claude-desktop';
export type AgentModelMappings = {
opus: string;
sonnet: string;
@@ -50,9 +52,29 @@ export const sameAgentModelMappings = (
&& Boolean(left.sonnet1m) === Boolean(right.sonnet1m)
&& Boolean(left.haiku1m) === Boolean(right.haiku1m)
&& (left.maxContextTokens ?? 200_000) === (right.maxContextTokens ?? 200_000)
&& (left.autoCompactPct ?? 100) === (right.autoCompactPct ?? 100)
&& (left.autoCompactPct ?? 90) === (right.autoCompactPct ?? 90)
&& Boolean(left.disableAutoCompact) === Boolean(right.disableAutoCompact);
export const resolveAgentModelMappingsDraftSource = <T>(
current: T,
applied: T | null | undefined,
fallback: T,
dirty: boolean,
): T => (dirty ? current : applied ?? fallback);
export const resolveAgentModelMappingsDraftSourceForClient = <T>(
currentByClient: Record<ClaudeModelMappingClientId, T>,
client: ClaudeModelMappingClientId,
applied: T | null | undefined,
fallback: T,
dirty: boolean,
): T => resolveAgentModelMappingsDraftSource(
currentByClient[client],
applied,
fallback,
dirty,
);
export function resolveAgentConfigurationAction({
client,
modificationState,
+14
View File
@@ -0,0 +1,14 @@
export const shouldShowOAuthLoginStatus = (status: string) => status === 'success';
export const createOAuthLoginSuccessCache = <T extends string>() => {
const providers = new Set<T>();
return {
mark(provider: T) {
providers.add(provider);
},
snapshot() {
return Array.from(providers);
},
};
};
+48
View File
@@ -10,6 +10,8 @@ import {
} from '../src/services/agentModelPicker';
import {
resolveAgentConfigurationAction,
resolveAgentModelMappingsDraftSourceForClient,
sameAgentModelMappings,
type AgentConfigurationClientId,
} from '../src/services/agentConfigurationDraft';
@@ -98,6 +100,52 @@ const appliedConfiguration = (
});
describe('agent configuration update action', () => {
test('Claude mapping clients keep their own unsaved draft after switching away and back', () => {
const codeDraft = { opus: 'code-opus', sonnet: 'code-sonnet', haiku: 'code-haiku' };
const desktopDraft = {
opus: 'desktop-opus',
sonnet: 'desktop-sonnet',
haiku: 'desktop-haiku',
};
const desktopFallback = {
opus: 'desktop-model',
sonnet: 'desktop-model',
haiku: 'desktop-model',
};
const drafts = {
'claude-code': codeDraft,
'claude-desktop': desktopDraft,
};
const dirtyByClient = {
'claude-code': true,
'claude-desktop': false,
};
// Switch from Claude Code to Desktop: Desktop still loads its own default.
expect(resolveAgentModelMappingsDraftSourceForClient(
drafts,
'claude-desktop',
null,
desktopFallback,
dirtyByClient['claude-desktop'],
)).toEqual(desktopFallback);
// Switch back to Claude Code: its unapplied selection must not be overwritten.
expect(resolveAgentModelMappingsDraftSourceForClient(
drafts,
'claude-code',
{ opus: 'applied', sonnet: 'applied', haiku: 'applied' },
desktopFallback,
dirtyByClient['claude-code'],
)).toEqual(codeDraft);
});
test('Claude Code missing auto-compact preference defaults to 90%', () => {
const mappings = { opus: 'model-a', sonnet: 'model-a', haiku: 'model-a' };
expect(sameAgentModelMappings(mappings, { ...mappings, autoCompactPct: 90 })).toBeTrue();
expect(sameAgentModelMappings(mappings, { ...mappings, autoCompactPct: 100 })).toBeFalse();
});
test.each<AgentConfigurationClientId>([
'claude-code',
'claude-desktop',
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, test } from 'bun:test';
import {
createOAuthLoginSuccessCache,
shouldShowOAuthLoginStatus,
} from '../src/services/oauthLoginState';
describe('OAuth login display state', () => {
test('only successful login states display a status badge', () => {
expect(shouldShowOAuthLoginStatus('idle')).toBeFalse();
expect(shouldShowOAuthLoginStatus('waiting')).toBeFalse();
expect(shouldShowOAuthLoginStatus('error')).toBeFalse();
expect(shouldShowOAuthLoginStatus('success')).toBeTrue();
});
test('successful providers remain cached across page remounts', () => {
const cache = createOAuthLoginSuccessCache<'codex' | 'claude'>();
cache.mark('codex');
expect(cache.snapshot()).toEqual(['codex']);
expect(cache.snapshot()).toEqual(['codex']);
});
});