15 files changed
+380
-110
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
7.2.127
|
||||
7.2.128
|
||||
Generated
+1
-1
@@ -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,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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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': 'モデルエイリアスを使用',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user