diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index 67f31f9..eb472ef 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -839,10 +839,12 @@ pub(crate) async fn fetch_codex_catalog_runtime_models( config: &GuiConfigFile, ) -> Result, String> { let (runtime, content) = tokio::join!( - fetch_codex_runtime_models(config.port, effective_agent_api_key(config)), + fetch_agent_model_payload(config.port, effective_agent_api_key(config)), fetch_management_config_yaml(config), ); - let mut runtime = runtime?; + // Codex selection and its written catalog must use the public /v1/models + // IDs, not the alternate catalog selected by client_version. + let mut runtime = codex_catalog::parse_public_models(&runtime?)?; codex_catalog::apply_configured_context_limits(&mut runtime, &content?)?; Ok(runtime) } diff --git a/src-tauri/src/codex_catalog.rs b/src-tauri/src/codex_catalog.rs index 9f8c870..f8823d5 100644 --- a/src-tauri/src/codex_catalog.rs +++ b/src-tauri/src/codex_catalog.rs @@ -126,6 +126,25 @@ pub(crate) fn current_catalog_json() -> Result { Ok(state.json.clone()) } +pub(crate) fn parse_public_models(payload: &Value) -> Result, String> { + let values = payload.get("data").and_then(Value::as_array) + .ok_or("Public model list response is missing a data array")?; + let mut entries = Vec::with_capacity(values.len()); + for value in values { + let id = value.get("id").and_then(Value::as_str).map(str::trim) + .filter(|id| !id.is_empty()) + .ok_or("Public model list entry is missing a model ID")?; + let mut entry = value.as_object().cloned().unwrap_or_default(); + entry.insert("slug".into(), Value::String(id.to_string())); + // Public IDs are already resolved by the core. Never reinterpret them + // using configuration mapping fields or generate additional IDs. + entry.remove("alias"); + entry.remove("fork"); + entries.push(Value::Object(entry)); + } + parse_runtime_models(&serde_json::json!({"data": entries})) +} + pub(crate) fn parse_runtime_models(payload: &Value) -> Result, String> { let values = payload .get("models") diff --git a/src-tauri/src/core_config/aliases.rs b/src-tauri/src/core_config/aliases.rs index d118839..5dadfbb 100644 --- a/src-tauri/src/core_config/aliases.rs +++ b/src-tauri/src/core_config/aliases.rs @@ -483,6 +483,7 @@ pub(crate) fn management_v8_yaml_to_legacy_view(content: &str) -> Result Result Some(if let Some(native) = &native { - let groups = nested_yaml_value(native, &["api-keys", provider]) - .cloned().unwrap_or_else(|| serde_norway::Value::Sequence(Vec::new())); - update_v8_provider_group_models(provider, &groups, &before.unwrap_or_else(|| serde_norway::Value::Sequence(Vec::new())), &after)? + let before = before.unwrap_or_else(|| serde_norway::Value::Sequence(Vec::new())); + let groups = match nested_yaml_value(native, &["api-keys", provider]) { + Some(groups) => groups.clone(), + None => group_legacy_provider_records(provider, &before)?, + }; + update_v8_provider_group_models(provider, &groups, &before, &after)? } else { group_legacy_provider_records(provider, &after)? }), None => None, }; diff --git a/src-tauri/src/core_config/settings.rs b/src-tauri/src/core_config/settings.rs index 398e04e..a598a0b 100644 --- a/src-tauri/src/core_config/settings.rs +++ b/src-tauri/src/core_config/settings.rs @@ -291,7 +291,15 @@ pub(crate) fn merge_core_config_yaml( config: &GuiConfigFile, ) -> Result { let base = match current { - Some(current) => current.to_string(), + Some(current) => { + let template_document: serde_norway::Value = serde_norway::from_str(template) + .map_err(|error| format!("Failed to parse kernel configuration template: {error}"))?; + if core_config_uses_v8(&template_document) { + migrate_legacy_core_config_to_v8(template, current)? + } else { + current.to_string() + } + } None => merge_core_config_fields(template, None)?, }; apply_gui_managed_settings(&base, config) diff --git a/src-tauri/src/core_config/yaml.rs b/src-tauri/src/core_config/yaml.rs index d8bc18d..dc7336c 100644 --- a/src-tauri/src/core_config/yaml.rs +++ b/src-tauri/src/core_config/yaml.rs @@ -1258,7 +1258,7 @@ pub(crate) fn migrate_legacy_core_config_to_v8( ).cloned() { set_core_yaml_path_value(&mut migrated, &["config-version"], version)?; } else { - remove_core_yaml_path_value(&mut migrated, &["config-version"]); + set_core_yaml_path_value(&mut migrated, &["config-version"], serde_norway::Value::Number(8.into()))?; } lift_legacy_core_config_fields( &mut migrated, diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index e477a5b..df42a98 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -1450,6 +1450,54 @@ fn agent_model_list_parser_exposes_aliases_as_selectable_model_ids() { ); } +#[tokio::test] +async fn codex_configuration_uses_public_v1_model_ids_for_selection_and_files() { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = std::thread::spawn(move || { + let mut paths = Vec::new(); + for _ in 0..2 { + let (mut socket, _) = listener.accept().unwrap(); + socket.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut request = Vec::new(); + let mut buffer = [0; 2048]; + while !request.windows(4).any(|w| w == b"\r\n\r\n") { + let count = socket.read(&mut buffer).unwrap(); + assert!(count > 0); + request.extend_from_slice(&buffer[..count]); + } + let request = String::from_utf8(request).unwrap(); + let path = request.split_whitespace().nth(1).unwrap().to_string(); + let body = if path == "/v1/models" { + assert!(request.to_lowercase().contains("authorization: bearer public-test-key\r\n")); + r#"{"data":[{"id":"public-model","alias":"not-an-id","slug":"not-public","context_length":64000},{"id":"another-model"}]}"# + } else { + assert_eq!(path, "/v8/management/config.yaml"); + "config-version: 8\n" + }; + write!(socket, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body).unwrap(); + paths.push(path); + } + assert!(paths.iter().any(|path| path == "/v1/models")); + }); + let config = GuiConfigFile { + port, + management_secret_key: "management-test-key".into(), + api_keys: vec![GuiApiKeyEntry { key: "public-test-key".into(), remark: String::new() }], + ..GuiConfigFile::default() + }; + let prepared = fetch_prepared_agent_models(AgentClient::Codex, &config).await.unwrap(); + server.join().unwrap(); + let mut ids: Vec<_> = prepared.models.iter().map(|model| model.name.as_str()).collect(); + ids.sort(); + assert_eq!(ids, ["another-model", "public-model"]); + let catalog: serde_json::Value = serde_json::from_str(prepared.codex_catalog.as_deref().unwrap()).unwrap(); + let mut written: Vec<_> = catalog["models"].as_array().unwrap().iter().map(|model| model["slug"].as_str().unwrap()).collect(); + written.sort(); + assert_eq!(ids, written); +} + #[test] fn kimi_grok_and_zcode_use_cpa_runtime_context_windows() { for client in [ diff --git a/src-tauri/src/tests/alias_save.rs b/src-tauri/src/tests/alias_save.rs index 07de110..79fe816 100644 --- a/src-tauri/src/tests/alias_save.rs +++ b/src-tauri/src/tests/alias_save.rs @@ -37,6 +37,19 @@ fn non_v8_management_yaml_is_returned_without_reformatting() { assert_eq!(management_v8_yaml_to_legacy_view(input).unwrap(), input); } +#[test] +fn v8_version_marker_preserves_legacy_values_until_canonical_fields_exist() { + let input = "config-version: 8\napi-keys: [client-key]\ncodex-api-key: [{api-key: upstream-key, models: [{name: model-a}]}]\n"; + let view = yaml_json(&management_v8_yaml_to_legacy_view(input).unwrap()); + assert_eq!(view["api-keys"], serde_json::json!(["client-key"])); + assert_eq!(view["codex-api-key"][0]["models"][0]["name"], "model-a"); + let mixed = "config-version: 8\napi-keys: {codex: []}\naccess: {api-keys: []}\ncodex-api-key: [{api-key: old-key}]\nclaude-api-key: [{api-key: retained-key}]\n"; + let view = yaml_json(&management_v8_yaml_to_legacy_view(mixed).unwrap()); + assert_eq!(view["api-keys"], serde_json::json!([])); + assert_eq!(view["codex-api-key"], serde_json::json!([])); + assert_eq!(view["claude-api-key"][0]["api-key"], "retained-key"); +} + #[test] fn v8_provider_names_belong_to_groups_and_preserve_legacy_view_identity() { let groups = serde_norway::from_str( diff --git a/src-tauri/src/tests/core_runtime.rs b/src-tauri/src/tests/core_runtime.rs index 0cce9a7..b29144e 100644 --- a/src-tauri/src/tests/core_runtime.rs +++ b/src-tauri/src/tests/core_runtime.rs @@ -639,7 +639,7 @@ fn v8_core_updates_preserve_partially_migrated_config_and_credentials() { let legacy = "# User configuration\nusage-statistics-enabled: true\nhost: 127.0.0.1\nport: 9527\nauth-dir: ../oauth\napi-keys: [client-key]\nremote-management: {secret-key: user-secret}\nproxy-url: direct\nrequest-retry: 9\ncodex-api-key: [{api-key: upstream-key, base-url: 'https://example.invalid/v1'}]\noauth-model-alias: {codex: [{name: model, alias: custom-model}]}\n"; let template = "observability: {usage: {usage-statistics-enabled: false}}\nserver: {host: '', port: 8317}\noauth: {auth-dir: '~/.cli-proxy-api'}\naccess: {api-keys: [your-api-key-1]}\nmanagement: {secret-key: ''}\nrequests: {proxy-url: ''}\nrouting: {retry: {request-retry: 3}}\n"; - for prefix in ["", "server: {host: 127.0.0.1}\n"] { + for prefix in ["", "config-version: 7\n", "server: {host: 127.0.0.1}\n"] { let original = format!("{prefix}{legacy}"); fs::write(install_dir.join(CORE_CONFIG_FILE), &original).unwrap(); for template_prefix in ["config-version: 8\n", ""] { @@ -651,6 +651,7 @@ fn v8_core_updates_preserve_partially_migrated_config_and_credentials() { fs::write(install_dir.join(CORE_CONFIG_FILE), &original).unwrap(); let document = serde_norway::from_str::(&migrated).unwrap(); + assert_eq!(document["config-version"].as_u64(), Some(8)); let settings = core_config_settings_from_value(&document).unwrap(); assert_eq!(settings.auth_dir, "../oauth", "credential directory changed during update"); assert_eq!(settings.port, 9527); @@ -671,6 +672,7 @@ fn v8_core_updates_preserve_partially_migrated_config_and_credentials() { gui.proxy_url = settings.proxy_url.clone(); let startup = merge_core_config_yaml(template, Some(&migrated), &gui).unwrap(); let startup = serde_norway::from_str::(&startup).unwrap(); + assert_eq!(startup["config-version"].as_u64(), Some(8)); let effective = core_config_settings_from_value(&startup).unwrap(); assert_eq!(auth_dir_path_for_core(&effective.auth_dir, &install_dir).unwrap(), root.join("oauth")); assert!(effective.usage_statistics_enabled); @@ -682,6 +684,19 @@ fn v8_core_updates_preserve_partially_migrated_config_and_credentials() { fs::remove_dir_all(root).unwrap(); } +#[test] +fn v7_config_startup_with_v8_template_writes_v8_version() { + for marker in ["", "config-version: 8\n"] { + let template = format!("{marker}server: {{port: 8317}}\naccess: {{api-keys: []}}\n"); + let current = "config-version: 7\ncodex-api-key: [{api-key: upstream-test, models: [{name: example}]}]\n"; + let rendered = merge_core_config_yaml(&template, Some(current), &GuiConfigFile::default()).unwrap(); + let document: serde_norway::Value = serde_norway::from_str(&rendered).unwrap(); + assert_eq!(document["config-version"].as_u64(), Some(8)); + assert_eq!(document["api-keys"]["codex"][0]["keys"][0]["api-key"], "upstream-test"); + assert!(document.get("codex-api-key").is_none()); + } +} + #[test] fn replacing_a_core_rejects_invalid_config_without_overwriting_files() { let root = agent_test_home("core-config-migrate-invalid"); diff --git a/src-tauri/src/tests/core_v8_contract.rs b/src-tauri/src/tests/core_v8_contract.rs index a083161..4df03bb 100644 --- a/src-tauri/src/tests/core_v8_contract.rs +++ b/src-tauri/src/tests/core_v8_contract.rs @@ -128,6 +128,131 @@ impl TestCore { } } +#[tokio::test] +#[ignore = "requires CPA_V8_TEST_CORE pointing to a v8 executable"] +async fn v8_claude_desktop_and_code_share_persisted_model_routes() { + check_claude_model_routes(None).await; +} + +#[tokio::test] +#[ignore = "requires CPA_V8_TEST_CORE pointing to a v8 executable"] +async fn v7_claude_routes_survive_v8_migration_and_reapply() { + for marker in ["", "config-version: 7\n"] { + check_claude_model_routes(Some(marker)).await; + } +} + +async fn check_claude_model_routes(legacy_marker: Option<&str>) { + let executable = fs::canonicalize(std::env::var_os("CPA_V8_TEST_CORE").expect("set CPA_V8_TEST_CORE")).unwrap(); + let directory = agent_test_home("v8-contract-claude-routes"); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + let mut core = TestCore { + child: None, config: directory.join("config.yaml"), directory, executable, + origin: format!("http://127.0.0.1:{port}"), + client: reqwest::Client::builder().no_proxy().timeout(Duration::from_secs(3)).build().unwrap(), + }; + let gui = GuiConfigFile { + port, management_secret_key: "isolated-test-secret".into(), + auth_dir: path_to_string(&core.directory.join("auth")), + api_keys: vec![GuiApiKeyEntry { key: "isolated-client-key".into(), remark: String::new() }], + ..GuiConfigFile::default() + }; + let initial = "config-version: 8\nmanagement: {disable-control-panel: true, disable-auto-update-panel: true}\napi-keys:\n codex:\n - name: test-upstream\n base-url: http://127.0.0.1:1/v1\n models: [{name: model-one}]\n keys: [{api-key: upstream-test, weight: 2}]\n"; + let migrated; + let initial = if let Some(marker) = legacy_marker { + let legacy = format!("{marker}codex-api-key:\n - api-key: upstream-test\n base-url: http://127.0.0.1:1/v1\n models: [{{name: model-one, alias: claude-sonnet-custom}}]\noauth-model-alias:\n codex: [{{name: oauth-original, alias: claude-opus-legacy, fork: true}}]\npayload:\n override:\n - models: [{{name: claude-sonnet-custom, protocol: codex}}]\n params: {{reasoning.effort: high, service_tier: priority}}\n"); + migrated = migrate_legacy_core_config_to_v8("config-version: 8\nserver: {port: 8317}\nmanagement: {disable-control-panel: true, disable-auto-update-panel: true}\n", &legacy).unwrap(); + let document: serde_norway::Value = serde_norway::from_str(&migrated).unwrap(); + assert_eq!(document["config-version"].as_u64(), Some(8)); + assert_eq!(document["oauth"]["model-alias"]["codex"][0]["alias"], "claude-opus-legacy"); + assert_eq!(document["requests"]["payload"]["override"][0]["params"]["service_tier"], "priority"); + for key in ["codex-api-key", "oauth-model-alias", "payload"] { + assert!(document.get(key).is_none(), "legacy field {key} must be migrated"); + } + migrated.as_str() + } else { initial }; + fs::write(&core.config, apply_gui_managed_settings(initial, &gui).unwrap()).unwrap(); + core.start().await; + let before = core.config_view().await; + let desktop = fetch_prepared_agent_models(AgentClient::ClaudeDesktop, &gui).await.unwrap(); + let mappings = ClaudeDesktopModelMappings { + desktop_models: Some(vec![ClaudeDesktopModelMapping { + model: if legacy_marker.is_some() { "claude-sonnet-custom" } else { "model-one" }.into(), + alias: if legacy_marker.is_some() { "" } else { "claude-sonnet-custom" }.into(), context_1m: false, + }]), + ..ClaudeDesktopModelMappings::all("model-one") + }; + let profile = commit_agent_with_core(&gui, Some(&mappings), &desktop.models, || { + build_claude_desktop_profile(None, &core.origin, "isolated-client-key", "model-one", &desktop.models, Some(&mappings)) + }).await.unwrap(); + let profile: serde_json::Value = serde_json::from_str(&profile).unwrap(); + assert_eq!(profile["inferenceModels"][0]["name"], "claude-sonnet-custom"); + assert_eq!(profile["inferenceGatewayBaseUrl"], core.origin); + let saved = core.config_view().await; + if legacy_marker.is_some() { + // Reapplying an existing public route must preserve its upstream, + // credentials, OAuth mappings, and reasoning/Fast overrides. + assert_eq!(saved["api-keys"], before["api-keys"]); + assert_eq!(saved["oauth"]["model-alias"], before["oauth"]["model-alias"]); + assert_eq!(saved["requests"]["payload"], before["requests"]["payload"]); + } + assert_eq!(saved["api-keys"]["codex"][0]["keys"], before["api-keys"]["codex"][0]["keys"]); + assert!(saved["api-keys"]["codex"][0]["models"].as_array().unwrap().iter() + .any(|entry| entry["name"] == "model-one" && entry["alias"] == "claude-sonnet-custom")); + + for client in [AgentClient::ClaudeDesktop, AgentClient::ClaudeCode] { + let mut available = None; + for _ in 0..50 { + let prepared = fetch_prepared_agent_models(client, &gui).await.unwrap(); + if prepared.models.iter().any(|model| model.name == "claude-sonnet-custom") { + available = Some(prepared.models); + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + let models = available.expect("Claude client must see the route published by the core"); + if client == AgentClient::ClaudeCode { + let settings = build_claude_agent_config(None, &core.origin, "isolated-client-key", "claude-sonnet-custom", &models, None).unwrap(); + let settings: serde_json::Value = serde_json::from_str(&settings).unwrap(); + assert_eq!(settings["env"]["ANTHROPIC_MODEL"], "claude-sonnet-custom"); + assert_eq!(settings["env"]["ANTHROPIC_BASE_URL"], core.origin); + } + } +} + +#[tokio::test] +#[ignore = "requires CPA_V8_TEST_CORE pointing to a v8 executable"] +async fn v8_model_write_migrates_legacy_provider_with_version_marker() { + let executable = fs::canonicalize(std::env::var_os("CPA_V8_TEST_CORE").expect("set CPA_V8_TEST_CORE")).unwrap(); + let directory = agent_test_home("v8-contract-mixed-models"); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + let mut core = TestCore { + child: None, config: directory.join("config.yaml"), directory, executable, + origin: format!("http://127.0.0.1:{port}"), + client: reqwest::Client::builder().no_proxy().timeout(Duration::from_secs(3)).build().unwrap(), + }; + let gui = GuiConfigFile { + port, management_secret_key: "isolated-test-secret".into(), + ..GuiConfigFile::default() + }; + let initial = format!("config-version: 8\nserver: {{host: 127.0.0.1, port: {port}}}\nmanagement: {{secret-key: isolated-test-secret, disable-control-panel: true, disable-auto-update-panel: true}}\noauth: {{auth-dir: '{}' }}\napi-keys: [isolated-client-key]\ncodex-api-key:\n - api-key: upstream-test\n base-url: http://127.0.0.1:1/v1\n models: [{{name: model-one}}]\n", path_to_string(&core.directory.join("auth"))); + fs::write(&core.config, initial).unwrap(); + core.start().await; + let current = fetch_management_config_yaml(&gui).await.unwrap(); + let mut updated: serde_norway::Value = serde_norway::from_str(¤t).unwrap(); + updated["codex-api-key"][0]["models"].as_sequence_mut().unwrap() + .push(serde_norway::from_str("{name: model-two}").unwrap()); + put_management_alias_config_changes(&gui, ¤t, &serde_norway::to_string(&updated).unwrap()).await.unwrap(); + let saved = core.config_view().await; + assert_eq!(saved["access"]["api-keys"], serde_json::json!(["isolated-client-key"])); + assert_eq!(saved["api-keys"]["codex"][0]["models"], serde_json::json!([{"name":"model-one"},{"name":"model-two"}])); + assert_eq!(saved["api-keys"]["codex"][0]["keys"][0]["api-key"], "upstream-test"); +} + #[tokio::test] #[ignore = "requires CPA_V8_TEST_CORE pointing to a v8 executable"] async fn v8_alias_save_preserves_multi_key_groups_and_restores_inheritance_after_failure() { diff --git a/src/components/TemplateConfigSection.tsx b/src/components/TemplateConfigSection.tsx index 92ef140..9addc2b 100644 --- a/src/components/TemplateConfigSection.tsx +++ b/src/components/TemplateConfigSection.tsx @@ -175,7 +175,7 @@ export function TemplateConfigSection({ groups, visibleGroups, onDirtyGroupsChan {groups.map((group) => { const dirty = group.fields.some((field) => drafts[templateFieldKey(field.path)]); const needsRestart = group.fields.some((field) => field.restart); - return + +
+ - - + +
+ + + diff --git a/src/pages/HomeDashboard.css b/src/pages/HomeDashboard.css index 2c466f7..10bb3ca 100644 --- a/src/pages/HomeDashboard.css +++ b/src/pages/HomeDashboard.css @@ -125,8 +125,6 @@ .home-runtime-state.pending { color: var(--amber-text); } .home-runtime-dot { width: 8px; height: 8px; flex: none; border-radius: 50%; background: currentColor; } .home-runtime-error { margin: 0; color: var(--error-color); font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; } -.home-dashboard .control-action-row .home-runtime-stop { color: var(--text-secondary); } -.home-dashboard .control-action-row .home-runtime-stop:hover:not(:disabled) { color: var(--error-color); border-color: color-mix(in srgb, var(--error-color) 35%, var(--border-color)); background: var(--bg-error-light); } .home-dashboard .home-runtime-refresh { width: 32px; height: 32px; min-width: 32px; border-color: transparent; background: transparent; } .home-dashboard .home-runtime-refresh svg { width: 18px; height: 18px; } diff --git a/src/pages/Kernel.tsx b/src/pages/Kernel.tsx index 0e54f3b..425add4 100644 --- a/src/pages/Kernel.tsx +++ b/src/pages/Kernel.tsx @@ -259,7 +259,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {