feat(agents): map Claude Desktop context capabilities from model API

This commit is contained in:
lzt404 committed 2026-08-02 15:17:07 +08:00
1 parent fb41286e97
commit 1edf647581
9 files changed
+210 -21

No files matched your search

+2
View File
@@ -388,6 +388,8 @@ fn prepare_catalog_with_sources(
alias: optional_map_string(&entry.value, "display_name").filter(|display| {
!display.eq_ignore_ascii_case(&string_value(&entry.value, "slug"))
}),
is_alias: false,
context_window: positive_u64_value(entry.value.get("context_window")),
})
.collect::<Vec<_>>();
let values = entries
+107 -16
View File
@@ -88,6 +88,7 @@ const CLAUDE_DESKTOP_PROFILE_ID: &str = "00000000-0000-4000-8000-000000831700";
const CLAUDE_DESKTOP_OPUS_MODEL_ID: &str = "claude-opus-5";
const CLAUDE_DESKTOP_SONNET_MODEL_ID: &str = "claude-sonnet-4-6";
const CLAUDE_DESKTOP_HAIKU_MODEL_ID: &str = "claude-haiku-4-5";
const CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW: u64 = 1_000_000;
const MODEL_ALIAS_CONFIG_SECTIONS: &[&str] = &[
"codex-api-key",
"openai-compatibility",
@@ -651,6 +652,10 @@ struct CodexModelCatalogUpdateResult {
struct AgentModelOption {
name: String,
alias: Option<String>,
#[serde(default)]
is_alias: bool,
#[serde(default)]
context_window: Option<u64>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
@@ -2544,14 +2549,23 @@ fn parse_agent_model_options(payload: &serde_json::Value) -> Result<Vec<AgentMod
.get("fork")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let context_window = [
"context_length",
"contextLength",
"ContextLength",
"context_window",
"contextWindow",
]
.into_iter()
.find_map(|key| item.get(key).and_then(json_positive_u64));
if let Some(model_alias) = model_alias {
if keep_original {
append_agent_model_option(&mut models, &name, display_name);
append_agent_model_option(&mut models, &name, display_name, false, context_window);
}
append_agent_model_option(&mut models, &model_alias, Some(name));
append_agent_model_option(&mut models, &model_alias, Some(name), true, context_window);
} else {
append_agent_model_option(&mut models, &name, display_name);
append_agent_model_option(&mut models, &name, display_name, false, context_window);
}
}
Ok(models)
@@ -2561,6 +2575,8 @@ fn append_agent_model_option(
models: &mut Vec<AgentModelOption>,
name: &str,
alias: Option<String>,
is_alias: bool,
context_window: Option<u64>,
) {
let name = name.trim();
if name.is_empty()
@@ -2578,6 +2594,8 @@ fn append_agent_model_option(
models.push(AgentModelOption {
name: name.to_string(),
alias,
is_alias,
context_window,
});
}
@@ -4452,6 +4470,7 @@ fn build_agent_updates_with_oauth(
&root_base,
api_key,
model,
models,
claude_desktop_model_mappings,
)
.or_else(|_| {
@@ -4460,6 +4479,7 @@ fn build_agent_updates_with_oauth(
&root_base,
api_key,
model,
models,
claude_desktop_model_mappings,
)
})?,
@@ -4658,6 +4678,8 @@ fn ordered_agent_models(
ordered.push(AgentModelOption {
name: selected_model.to_string(),
alias: None,
is_alias: false,
context_window: None,
});
}
for model in models {
@@ -4683,6 +4705,7 @@ fn build_claude_desktop_profile(
base_url: &str,
api_key: &str,
model: &str,
models: &[AgentModelOption],
mappings: Option<&ClaudeDesktopModelMappings>,
) -> Result<String, String> {
let mut root = parse_agent_json_object(existing, "Claude Desktop 网关配置")?;
@@ -4710,13 +4733,13 @@ fn build_claude_desktop_profile(
"inferenceProvider".to_string(),
serde_json::json!("gateway"),
);
let _mappings = mappings
let mappings = mappings
.cloned()
.unwrap_or_else(|| ClaudeDesktopModelMappings::all(model));
let inference_models = vec![
serde_json::json!(CLAUDE_DESKTOP_OPUS_MODEL_ID),
serde_json::json!(CLAUDE_DESKTOP_SONNET_MODEL_ID),
serde_json::json!(CLAUDE_DESKTOP_HAIKU_MODEL_ID),
claude_desktop_inference_model(CLAUDE_DESKTOP_OPUS_MODEL_ID, &mappings.opus, models),
claude_desktop_inference_model(CLAUDE_DESKTOP_SONNET_MODEL_ID, &mappings.sonnet, models),
claude_desktop_inference_model(CLAUDE_DESKTOP_HAIKU_MODEL_ID, &mappings.haiku, models),
];
root.insert(
"inferenceModels".to_string(),
@@ -4725,6 +4748,24 @@ fn build_claude_desktop_profile(
render_agent_json(root, "Claude Desktop 网关配置")
}
fn claude_desktop_inference_model(
route_model: &str,
source_model: &str,
models: &[AgentModelOption],
) -> serde_json::Value {
let context_window = models
.iter()
.find(|model| model.name.eq_ignore_ascii_case(source_model))
.and_then(|model| model.context_window);
let mut entry = serde_json::Map::new();
entry.insert("name".to_string(), serde_json::json!(route_model));
if context_window.is_some_and(|window| window >= CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW) {
entry.insert("supports1m".to_string(), serde_json::json!(true));
entry.insert("prefer1m".to_string(), serde_json::json!(true));
}
serde_json::Value::Object(entry)
}
fn build_claude_desktop_meta(existing: Option<&str>) -> Result<String, String> {
let mut root = parse_agent_json_object(existing, "Claude Desktop 配置索引")?;
let entries = ensure_json_array_entry(&mut root, "entries");
@@ -6890,6 +6931,8 @@ fn fresh_agent_contents(
let models = [AgentModelOption {
name: model.to_string(),
alias: None,
is_alias: false,
context_window: None,
}];
fresh_agent_contents_with_oauth(client, port, api_key, model, &models, false, None)
}
@@ -6917,6 +6960,7 @@ fn fresh_agent_contents_with_oauth(
&root_base,
api_key,
model,
models,
claude_desktop_model_mappings,
)?,
build_claude_desktop_meta(None)?,
@@ -7727,6 +7771,8 @@ fn reset_agent_configuration_to_default(
models: &[AgentModelOption {
name: model.to_string(),
alias: None,
is_alias: false,
context_window: None,
}],
codex_catalog,
oauth_configuration: false,
@@ -16715,6 +16761,8 @@ mod tests {
.map(|name| AgentModelOption {
name: (*name).to_string(),
alias: None,
is_alias: false,
context_window: None,
})
.collect()
}
@@ -16883,6 +16931,7 @@ mod tests {
"http://127.0.0.1:8317",
DEFAULT_API_KEY,
"gpt-test",
&[],
None,
)
.unwrap();
@@ -18195,6 +18244,7 @@ model:
"http://127.0.0.1:8317",
DEFAULT_API_KEY,
"claude-sonnet-test",
&[],
None,
)
.unwrap();
@@ -18213,9 +18263,9 @@ model:
assert_eq!(
profile["inferenceModels"],
serde_json::json!([
CLAUDE_DESKTOP_OPUS_MODEL_ID,
CLAUDE_DESKTOP_SONNET_MODEL_ID,
CLAUDE_DESKTOP_HAIKU_MODEL_ID
{ "name": CLAUDE_DESKTOP_OPUS_MODEL_ID },
{ "name": CLAUDE_DESKTOP_SONNET_MODEL_ID },
{ "name": CLAUDE_DESKTOP_HAIKU_MODEL_ID }
])
);
assert_eq!(meta["appliedId"], CLAUDE_DESKTOP_PROFILE_ID);
@@ -18236,11 +18286,32 @@ model:
sonnet: "gpt-5.6".to_string(),
haiku: "gpt-5.6-mini".to_string(),
};
let models = vec![
AgentModelOption {
name: mappings.opus.clone(),
alias: None,
is_alias: false,
context_window: Some(1_000_000),
},
AgentModelOption {
name: mappings.sonnet.clone(),
alias: None,
is_alias: false,
context_window: Some(272_000),
},
AgentModelOption {
name: mappings.haiku.clone(),
alias: None,
is_alias: false,
context_window: Some(1_000_000),
},
];
let profile = build_claude_desktop_profile(
None,
"http://127.0.0.1:8317",
DEFAULT_API_KEY,
"gpt-5.6-sol",
&models,
Some(&mappings),
)
.unwrap();
@@ -18249,9 +18320,17 @@ model:
assert_eq!(
profile["inferenceModels"],
serde_json::json!([
CLAUDE_DESKTOP_OPUS_MODEL_ID,
CLAUDE_DESKTOP_SONNET_MODEL_ID,
CLAUDE_DESKTOP_HAIKU_MODEL_ID
{
"name": CLAUDE_DESKTOP_OPUS_MODEL_ID,
"supports1m": true,
"prefer1m": true
},
{ "name": CLAUDE_DESKTOP_SONNET_MODEL_ID },
{
"name": CLAUDE_DESKTOP_HAIKU_MODEL_ID,
"supports1m": true,
"prefer1m": true
}
])
);
assert!(!profile.to_string().contains("gpt-"));
@@ -18429,9 +18508,9 @@ model:
let models = parse_agent_model_options(&serde_json::json!({
"object": "list",
"data": [
{"id": "gpt-5", "display_name": "GPT 5"},
{"name": "claude-sonnet", "alias": "claude-sonnet-xhigh", "fork": true},
{"name": "hidden-original", "alias": "visible-alias"},
{"id": "gpt-5", "display_name": "GPT 5", "context_length": 272000},
{"name": "claude-sonnet", "alias": "claude-sonnet-xhigh", "fork": true, "contextLength": "1000000"},
{"name": "hidden-original", "alias": "visible-alias", "context_window": 128000},
"deepseek-chat",
{"id": "GPT-5"},
{"id": ""}
@@ -18445,22 +18524,32 @@ model:
AgentModelOption {
name: "gpt-5".to_string(),
alias: Some("GPT 5".to_string()),
is_alias: false,
context_window: Some(272_000),
},
AgentModelOption {
name: "claude-sonnet".to_string(),
alias: None,
is_alias: false,
context_window: Some(1_000_000),
},
AgentModelOption {
name: "claude-sonnet-xhigh".to_string(),
alias: Some("claude-sonnet".to_string()),
is_alias: true,
context_window: Some(1_000_000),
},
AgentModelOption {
name: "visible-alias".to_string(),
alias: Some("hidden-original".to_string()),
is_alias: true,
context_window: Some(128_000),
},
AgentModelOption {
name: "deepseek-chat".to_string(),
alias: None,
is_alias: false,
context_window: None,
},
]
);
@@ -18566,6 +18655,8 @@ model:
let models = vec![AgentModelOption {
name: "gpt-5.4".to_string(),
alias: Some("GPT 5.4".to_string()),
is_alias: false,
context_window: None,
}];
assert_eq!(
+2
View File
@@ -550,6 +550,8 @@ export const jaOverrides = {
'agents.modify.oauthConfiguration': 'OAuth 設定方式',
'agents.claudeDesktopMapping.title': 'Claude Desktop モデル → 上流モデル',
'agents.claudeDesktopMapping.description': '各 Claude Desktop モデルが実際に呼び出す上流モデルを選択します。',
'agents.claudeDesktopMapping.customMapping': 'カスタムマッピング',
'agents.claudeDesktopMapping.customMappingHint': '有効にするとエイリアスモデルのみ、無効にするとエイリアス未設定の元モデルのみ表示します。',
'agents.claudeDesktopMapping.opus': 'Claude Opus 5 のマッピング先',
'agents.claudeDesktopMapping.sonnet': 'Claude Sonnet 4.6 のマッピング先',
'agents.claudeDesktopMapping.haiku': 'Claude Haiku 4.5 のマッピング先',
+4
View File
@@ -548,6 +548,8 @@ export const zhCN = {
'agents.pending': '尚未应用',
'agents.claudeDesktopMapping.title': 'Claude Desktop 模型 → 上游模型',
'agents.claudeDesktopMapping.description': '选择每个 Claude Desktop 模型实际调用的上游模型。',
'agents.claudeDesktopMapping.customMapping': '自定义映射',
'agents.claudeDesktopMapping.customMappingHint': '开启后只显示别名模型;关闭后只显示未设置别名的原始模型。',
'agents.claudeDesktopMapping.opus': 'Claude Opus 5 映射到',
'agents.claudeDesktopMapping.sonnet': 'Claude Sonnet 4.6 映射到',
'agents.claudeDesktopMapping.haiku': 'Claude Haiku 4.5 映射到',
@@ -1438,6 +1440,8 @@ export const en: Record<MessageKey, string> = {
'agents.pending': 'Not Applied',
'agents.claudeDesktopMapping.title': 'Claude Desktop Model → Upstream Model',
'agents.claudeDesktopMapping.description': 'Choose the upstream model each Claude Desktop model actually calls.',
'agents.claudeDesktopMapping.customMapping': 'Custom Mapping',
'agents.claudeDesktopMapping.customMappingHint': 'When enabled, only aliased models are shown; when disabled, only original models without aliases are shown.',
'agents.claudeDesktopMapping.opus': 'Map Claude Opus 5 to',
'agents.claudeDesktopMapping.sonnet': 'Map Claude Sonnet 4.6 to',
'agents.claudeDesktopMapping.haiku': 'Map Claude Haiku 4.5 to',
+30 -5
View File
@@ -34,6 +34,7 @@ import opencodeIcon from '../assets/icons/opencode.svg';
import {
agentModelAlias,
filterAgentModels,
filterAgentModelsByAlias,
findAgentModel,
resolveAgentModelSelection,
} from '../services/agentModelPicker';
@@ -298,7 +299,9 @@ function AgentModelPicker({
() => visibleModels.map((model) => ({ name: model.name, alias: model.alias ?? '' })),
[visibleModels],
);
const selectedAlias = agentModelAlias(models, value);
const selectedModel = findAgentModel(models, value);
const selectedName = selectedModel?.name ?? '';
const selectedAlias = selectedName ? agentModelAlias(models, selectedName) : '';
const updateDropdownLayout = useCallback(() => {
const root = rootRef.current;
@@ -411,8 +414,8 @@ function AgentModelPicker({
}}
>
<span>
<strong title={value || undefined}>
{value || (loading ? t('agents.model.loading') : error ? t('agents.model.loadFailed') : models.length ? t('agents.model.select') : t('agents.model.none'))}
<strong title={selectedName || undefined}>
{selectedName || (loading ? t('agents.model.loading') : error ? t('agents.model.loadFailed') : models.length ? t('agents.model.select') : t('agents.model.none'))}
</strong>
{selectedAlias ? <small title={selectedAlias}>{selectedAlias}</small> : null}
</span>
@@ -513,6 +516,7 @@ export function AgentsPage() {
const [claudeDesktopModelMappingsDraft, setClaudeDesktopModelMappingsDraft] = useState<ClaudeDesktopModelMappings>(
createClaudeDesktopModelMappings(''),
);
const [claudeDesktopCustomMapping, setClaudeDesktopCustomMapping] = useState(false);
const [loading, setLoading] = useState(true);
const [modelLoading, setModelLoading] = useState(false);
const [busyAction, setBusyAction] = useState<
@@ -643,6 +647,10 @@ export function AgentsPage() {
const savedSelectedModel = modelByClient[selected] ?? '';
const selectedModelOption = findAgentModel(models, savedSelectedModel);
const selectedModel = selectedModelOption?.name ?? '';
const claudeDesktopModels = useMemo(
() => filterAgentModelsByAlias(models, claudeDesktopCustomMapping),
[claudeDesktopCustomMapping, models],
);
useEffect(() => {
if (selected !== 'claude-desktop' || !selectedModel) return;
@@ -1105,14 +1113,31 @@ export function AgentsPage() {
<strong>{t('agents.claudeDesktopMapping.title')}</strong>
<span>{t('agents.claudeDesktopMapping.description')}</span>
</div>
{claudeDesktopMappingDraftChanged ? <span className="agent-pending-badge">{t('agents.pending')}</span> : null}
<div className="agent-section-heading-actions">
<label
className="agent-claude-desktop-mapping-filter"
title={t('agents.claudeDesktopMapping.customMappingHint')}
>
<span>{t('agents.claudeDesktopMapping.customMapping')}</span>
<span className="switch-control">
<input
type="checkbox"
checked={claudeDesktopCustomMapping}
onChange={(event) => setClaudeDesktopCustomMapping(event.currentTarget.checked)}
disabled={busy || modelLoading}
/>
<span className="switch-track" />
</span>
</label>
{claudeDesktopMappingDraftChanged ? <span className="agent-pending-badge">{t('agents.pending')}</span> : null}
</div>
</div>
<div className="agent-claude-desktop-mapping-grid">
{claudeDesktopMappingRoles.map((role) => (
<div className="agent-claude-desktop-mapping-row" key={role.key}>
<strong>{t(role.labelKey)}</strong>
<AgentModelPicker
models={models}
models={claudeDesktopModels}
value={claudeDesktopModelMappingsDraft[role.key]}
loading={modelLoading}
error={modelError}
+7
View File
@@ -28,6 +28,13 @@ export function filterAgentModels(models: ModelOption[], search: string): ModelO
.map((item) => item.model);
}
export function filterAgentModelsByAlias(
models: ModelOption[],
aliasesOnly: boolean,
): ModelOption[] {
return models.filter((model) => Boolean(model.isAlias) === aliasesOnly);
}
export function hasExactAgentModel(models: ModelOption[], value: string): boolean {
const query = normalized(value);
if (!query) return false;
+2
View File
@@ -6,6 +6,8 @@ const modelText = (key: Parameters<typeof translate>[1]) => translate(getCurrent
export type ModelOption = {
name: string;
alias?: string;
isAlias?: boolean;
contextWindow?: number;
thinking?: Record<string, unknown>;
};
export type ModelProvider = 'gemini' | 'codex' | 'claude' | 'openai';
+42
View File
@@ -5166,6 +5166,39 @@ textarea:focus-visible {
font-size: var(--font-size-label);
}
.agent-section-heading-actions {
display: flex !important;
flex: none;
align-items: center;
justify-content: flex-end;
gap: 8px !important;
}
.agent-claude-desktop-mapping-filter {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: 1px solid var(--theme-e3e1db);
border-radius: 7px;
background: var(--theme-fff);
color: var(--theme-5f5a53);
cursor: pointer;
font-size: var(--font-size-label);
font-weight: 700;
}
.agent-claude-desktop-mapping-filter:has(input:checked) {
border-color: var(--theme-8069ad);
background: var(--theme-f1ecfb);
color: var(--theme-554075);
}
.agent-claude-desktop-mapping-filter:has(input:disabled) {
cursor: not-allowed;
opacity: 0.62;
}
.agent-section-heading {
display: flex;
align-items: center;
@@ -6950,6 +6983,15 @@ textarea:focus-visible {
}
@container agent-panel (max-width: 640px) {
.agent-claude-desktop-mapping > .agent-section-heading {
align-items: stretch;
flex-direction: column;
}
.agent-claude-desktop-mapping .agent-section-heading-actions {
justify-content: space-between;
}
.agent-claude-desktop-mapping-grid {
grid-template-columns: 1fr;
}
+14
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
import {
agentModelAlias,
filterAgentModels,
filterAgentModelsByAlias,
findAgentModel,
hasExactAgentModel,
resolveAgentModelSelection,
@@ -49,4 +50,17 @@ describe('智能体模型选择器', () => {
expect(findAgentModel(models, 'codex')?.name).toBeUndefined();
expect(findAgentModel(models, 'gpt-5.2-codex')?.name).toBe('gpt-5.2-codex');
});
test('Claude Desktop 自定义映射只显示对应类型的模型', () => {
const mixedModels = [
{ name: 'gpt-original', alias: 'GPT Original', isAlias: false },
{ name: 'gpt-high', alias: 'gpt-original', isAlias: true },
{ name: 'claude-original' },
];
expect(filterAgentModelsByAlias(mixedModels, false).map((model) => model.name))
.toEqual(['gpt-original', 'claude-original']);
expect(filterAgentModelsByAlias(mixedModels, true).map((model) => model.name))
.toEqual(['gpt-high']);
});
});