fix(v8): preserve migrated config and correct provider requests
- Preserve provider credentials, aliases, headers, and network settings during migration - Normalize Claude cloak metadata and repair previously saved invalid types - Use streaming Gemini health probes and honor group and key proxy settings - Retain Codex customizations for temporarily unavailable models - Add regression tests for migration, metadata, proxy routing, and catalog persistence
This commit is contained in:
1 parent
2fd0f73f4c
commit
06fd58d3d8
15 files changed
+433
-35
No files matched your search
@@ -370,7 +370,17 @@ fn save_for_state(
|
||||
state: &mut CatalogState,
|
||||
) -> Result<CatalogEditorSnapshot, String> {
|
||||
let snapshot = snapshot_for_state(runtime_models, state)?;
|
||||
let customizations = customizations_from_request(&snapshot, request)?;
|
||||
let mut customizations = customizations_from_request(&snapshot, request)?;
|
||||
// The public catalog can temporarily omit disabled or unavailable models.
|
||||
// Editing visible models must not erase the saved settings for those IDs.
|
||||
let visible: HashSet<String> = snapshot.models.iter()
|
||||
.map(|model| normalize_id(&model.slug))
|
||||
.collect();
|
||||
for (slug, configuration) in &state.customizations {
|
||||
if !visible.contains(slug) {
|
||||
customizations.insert(slug.clone(), configuration.clone());
|
||||
}
|
||||
}
|
||||
let saved = SavedCustomizations {
|
||||
version: 1,
|
||||
models: customizations,
|
||||
@@ -423,6 +433,52 @@ mod tests {
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_visible_models_preserves_unavailable_models_and_allows_visible_reset() {
|
||||
let mut state = CatalogState {
|
||||
sources: parse_sources(MODEL_CATALOG_JSON).unwrap(),
|
||||
json: MODEL_CATALOG_JSON.to_string(),
|
||||
customizations: Default::default(),
|
||||
};
|
||||
let hidden = serde_json::json!({
|
||||
"base_instructions": "Saved prompt for temporarily unavailable A",
|
||||
"context_window": 131_072,
|
||||
"max_context_window": 262_144,
|
||||
}).as_object().unwrap().clone();
|
||||
state.customizations.insert("model-a".to_string(), hidden.clone());
|
||||
let runtime = vec![runtime_model("model-b")];
|
||||
let snapshot = snapshot_for_state(&runtime, &state).unwrap();
|
||||
let mut configuration = snapshot.models[0].configuration.clone();
|
||||
configuration.insert("display_name".to_string(), Value::String("Edited B".to_string()));
|
||||
let path = temporary_path();
|
||||
let saved = save_for_state(&path, &runtime, CatalogEditorRequest {
|
||||
revision: snapshot.revision,
|
||||
models: vec![CatalogEditorModelRequest { slug: "model-b".to_string(), configuration }],
|
||||
}, &mut state).unwrap();
|
||||
let persisted = decode_customizations(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(persisted.get("model-a"), Some(&hidden));
|
||||
assert_eq!(persisted["model-b"]["display_name"], "Edited B");
|
||||
|
||||
save_for_state(&path, &runtime, CatalogEditorRequest {
|
||||
revision: saved.revision,
|
||||
models: vec![CatalogEditorModelRequest {
|
||||
slug: "model-b".to_string(), configuration: saved.models[0].defaults.clone(),
|
||||
}],
|
||||
}, &mut state).unwrap();
|
||||
assert!(!state.customizations.contains_key("model-b"));
|
||||
assert_eq!(state.customizations.get("model-a"), Some(&hidden));
|
||||
|
||||
let empty = snapshot_for_state(&[], &state).unwrap();
|
||||
save_for_state(&path, &[], CatalogEditorRequest {
|
||||
revision: empty.revision, models: vec![],
|
||||
}, &mut state).unwrap();
|
||||
assert_eq!(decode_customizations(&std::fs::read(&path).unwrap()).unwrap().get("model-a"), Some(&hidden));
|
||||
let restored = snapshot_for_state(&[runtime_model("model-a")], &state).unwrap();
|
||||
assert_eq!(restored.models[0].configuration["base_instructions"], hidden["base_instructions"]);
|
||||
assert_eq!(restored.models[0].configuration["context_window"], 131_072);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn customizations_persist_apply_and_restore_to_the_current_template() {
|
||||
let sources = parse_sources(MODEL_CATALOG_JSON).unwrap();
|
||||
|
||||
@@ -65,6 +65,8 @@ const PATHS: &[(&str, &str)] = &[
|
||||
("oauth/providers/xai", "xai"),
|
||||
("server/host", "host"),
|
||||
("server/port", "port"),
|
||||
("server/trusted-proxies", "trusted-proxies"),
|
||||
("server/discovery", "discovery"),
|
||||
("server/tls", "tls"),
|
||||
("server/commercial-mode", "commercial-mode"),
|
||||
("management", "remote-management"),
|
||||
|
||||
@@ -1276,14 +1276,12 @@ fn lift_legacy_core_config_fields(
|
||||
template: &serde_norway::Mapping,
|
||||
) -> Result<(), String> {
|
||||
let copy = |document: &mut serde_norway::Value, legacy_path: &[&str], v8_path: &[&str]| -> Result<(), String> {
|
||||
let Some(value) = nested_yaml_value(legacy, legacy_path).cloned() else {
|
||||
// Read the remaining source: narrower moves may already have consumed
|
||||
// children which now belong elsewhere in the v8 layout.
|
||||
let Some(value) = document.as_mapping().and_then(|root| nested_yaml_value(root, legacy_path)).cloned() else {
|
||||
return Ok(());
|
||||
};
|
||||
// A partially migrated file can contain both spellings. Prefer the
|
||||
// canonical v8 value in that case; otherwise move the legacy value.
|
||||
if nested_yaml_value(legacy, v8_path).is_none() {
|
||||
set_core_yaml_path_value(document, v8_path, value)?;
|
||||
}
|
||||
lift_legacy_config_value(document, legacy, v8_path, value)?;
|
||||
if nested_yaml_value(template, legacy_path).is_none() {
|
||||
remove_core_yaml_path_value(document, legacy_path);
|
||||
}
|
||||
@@ -1292,6 +1290,7 @@ fn lift_legacy_core_config_fields(
|
||||
for (legacy_path, v8_path) in [
|
||||
(&["host"][..], &["server", "host"][..]),
|
||||
(&["port"], &["server", "port"]),
|
||||
(&["tls"], &["server", "tls"]),
|
||||
(&["commercial-mode"], &["server", "commercial-mode"]),
|
||||
(&["auth-dir"], &["oauth", "auth-dir"]),
|
||||
(&["debug"], &["observability", "logs", "debug"]),
|
||||
@@ -1313,14 +1312,6 @@ fn lift_legacy_core_config_fields(
|
||||
] {
|
||||
copy(document, legacy_path, v8_path)?;
|
||||
}
|
||||
if let Some(tls) = yaml_mapping_value(legacy, "tls").filter(|value| value.is_mapping()).cloned() {
|
||||
if nested_yaml_value(legacy, &["server", "tls"]).is_none() {
|
||||
set_core_yaml_path_value(document, &["server", "tls"], tls)?;
|
||||
}
|
||||
if yaml_mapping_value(template, "tls").is_none() {
|
||||
remove_core_yaml_path_value(document, &["tls"]);
|
||||
}
|
||||
}
|
||||
if let Some(keys) = yaml_mapping_value(legacy, "api-keys").filter(|value| value.is_sequence()).cloned() {
|
||||
if nested_yaml_value(legacy, &["access", "api-keys"]).is_none() {
|
||||
set_core_yaml_path_value(document, &["access", "api-keys"], keys)?;
|
||||
@@ -1336,15 +1327,11 @@ fn lift_legacy_core_config_fields(
|
||||
remove_core_yaml_path_value(document, &["api-keys"]);
|
||||
}
|
||||
}
|
||||
let has_canonical_upstream_mapping = yaml_mapping_value(legacy, "api-keys")
|
||||
.is_some_and(serde_norway::Value::is_mapping);
|
||||
for (legacy_name, provider) in V8_PROVIDER_FAMILIES {
|
||||
let Some(records) = yaml_mapping_value(legacy, legacy_name) else {
|
||||
continue;
|
||||
};
|
||||
if !has_canonical_upstream_mapping
|
||||
&& nested_yaml_value(legacy, &["api-keys", provider]).is_none()
|
||||
{
|
||||
if nested_yaml_value(legacy, &["api-keys", provider]).is_none() {
|
||||
let grouped = group_legacy_provider_records(provider, records)?;
|
||||
set_core_yaml_path_value(document, &["api-keys", provider], grouped)?;
|
||||
}
|
||||
@@ -1358,6 +1345,49 @@ fn lift_legacy_core_config_fields(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Structs have independent leaf settings. User-owned maps (aliases, payload
|
||||
// params, headers, etc.) instead retain the canonical value as a whole.
|
||||
fn lift_legacy_config_value(
|
||||
document: &mut serde_norway::Value,
|
||||
original: &serde_norway::Mapping,
|
||||
path: &[&str],
|
||||
value: serde_norway::Value,
|
||||
) -> Result<(), String> {
|
||||
// Explicit canonical null/scalar parents must not be recreated by a
|
||||
// narrower legacy spelling such as claude-header-defaults.
|
||||
if (1..path.len()).any(|length| {
|
||||
nested_yaml_value(original, &path[..length]).is_some_and(|value| !value.is_mapping())
|
||||
}) {
|
||||
return Ok(());
|
||||
}
|
||||
let is_struct = matches!(path,
|
||||
["server", "tls"] | ["server", "discovery"]
|
||||
| ["server", "discovery", "interfaces"] | ["management"]
|
||||
| ["credentials", "concurrency"] | ["credentials", "in-flight"]
|
||||
| ["requests", "streaming"] | ["observability", "pprof"]
|
||||
| ["oauth", "providers", "codex"] | ["oauth", "providers", "claude"]
|
||||
| ["oauth", "providers", "antigravity"] | ["oauth", "providers", "devin"]
|
||||
| ["oauth", "providers", "xai"]
|
||||
| ["oauth", "providers", "codex", "live-media-relay"]
|
||||
| ["oauth", "providers", "codex", "header-defaults"]
|
||||
| ["oauth", "providers", "claude", "header-defaults"]
|
||||
| ["oauth", "providers", "claude", "claude-code"]
|
||||
| ["oauth", "providers", "antigravity", "connection-pool"]
|
||||
);
|
||||
let canonical = nested_yaml_value(original, path);
|
||||
if is_struct && value.is_mapping() && canonical.is_none_or(serde_norway::Value::is_mapping) {
|
||||
for (key, child) in value.as_mapping().unwrap() {
|
||||
let key = key.as_str().ok_or("Configuration field must be a string")?;
|
||||
let mut child_path = path.to_vec();
|
||||
child_path.push(key);
|
||||
lift_legacy_config_value(document, original, &child_path, child.clone())?;
|
||||
}
|
||||
} else if canonical.is_none() {
|
||||
set_core_yaml_path_value(document, path, value)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_core_yaml_path_value(document: &mut serde_norway::Value, path: &[&str]) -> bool {
|
||||
let Some((key, parents)) = path.split_last() else {
|
||||
return false;
|
||||
|
||||
@@ -26,6 +26,8 @@ pub(crate) struct ProviderHealthProbeRequest {
|
||||
protocol: String,
|
||||
#[serde(default)]
|
||||
source_provider: Option<String>,
|
||||
#[serde(default)]
|
||||
proxy_url: Option<String>,
|
||||
timeout_ms: Option<u64>,
|
||||
#[serde(default)]
|
||||
model: String,
|
||||
@@ -424,6 +426,7 @@ async fn probe_core_model(
|
||||
}).to_string(),
|
||||
protocol: "openai-chat".to_string(),
|
||||
source_provider: None,
|
||||
proxy_url: None,
|
||||
timeout_ms,
|
||||
model,
|
||||
source: String::new(),
|
||||
@@ -447,11 +450,7 @@ pub(crate) async fn provider_health_probe(
|
||||
let url = reqwest::Url::parse(request.url.trim())
|
||||
.map_err(|error| format!("Invalid health check URL: {error}"))?;
|
||||
let config = gui_config_state.snapshot()?;
|
||||
let proxy_url = url
|
||||
.host_str()
|
||||
.filter(|host| !is_loopback_host(host))
|
||||
.map(|_| config.proxy_url.as_str())
|
||||
.unwrap_or_default();
|
||||
let proxy_url = provider_health_proxy_url(&url, request.proxy_url.as_deref(), &config.proxy_url);
|
||||
let client = build_http_client_with_proxy(
|
||||
reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
@@ -462,6 +461,14 @@ pub(crate) async fn provider_health_probe(
|
||||
execute_health_probe(Some(&app), client, request).await
|
||||
}
|
||||
|
||||
fn provider_health_proxy_url<'a>(url: &reqwest::Url, requested: Option<&'a str>, global: &'a str) -> &'a str {
|
||||
if url.host_str().is_some_and(is_loopback_host) {
|
||||
return "";
|
||||
}
|
||||
let proxy = requested.map(str::trim).filter(|value| !value.is_empty()).unwrap_or(global.trim());
|
||||
if proxy.eq_ignore_ascii_case("direct") { "" } else { proxy }
|
||||
}
|
||||
|
||||
async fn execute_health_probe(
|
||||
usage_app: Option<&tauri::AppHandle>,
|
||||
client: reqwest::Client,
|
||||
|
||||
@@ -2,6 +2,34 @@ use super::*;
|
||||
use crate::GuiApiKeyEntry;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
#[test]
|
||||
fn provider_proxy_override_preserves_direct_and_global_inheritance() {
|
||||
let upstream = reqwest::Url::parse("https://provider.invalid/v1/responses").unwrap();
|
||||
let global = "http://127.0.0.1:18080";
|
||||
assert_eq!(provider_health_proxy_url(&upstream, None, global), global);
|
||||
assert_eq!(provider_health_proxy_url(&upstream, Some(" "), global), global);
|
||||
assert_eq!(provider_health_proxy_url(&upstream, Some(" direct "), global), "");
|
||||
assert_eq!(provider_health_proxy_url(&upstream, Some("socks5://127.0.0.1:19080"), global), "socks5://127.0.0.1:19080");
|
||||
let local = reqwest::Url::parse("http://127.0.0.1:8317/v1/responses").unwrap();
|
||||
assert_eq!(provider_health_proxy_url(&local, None, global), "");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_health_uses_request_proxy_instead_of_global_proxy() {
|
||||
let (port, server) = mock_core_response("200 OK", "text/event-stream", "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"OK\"}]}}]}\n\n", "").await;
|
||||
let request: ProviderHealthProbeRequest = serde_json::from_value(serde_json::json!({
|
||||
"url": "http://provider.invalid/v1beta/models/test:streamGenerateContent?alt=sse",
|
||||
"header": {}, "data": "{}", "protocol": "gemini",
|
||||
"proxyUrl": format!("http://127.0.0.1:{port}"),
|
||||
})).unwrap();
|
||||
let url = reqwest::Url::parse(&request.url).unwrap();
|
||||
let selected = provider_health_proxy_url(&url, request.proxy_url.as_deref(), "http://127.0.0.1:1");
|
||||
let client = build_http_client_with_proxy(reqwest::Client::builder().timeout(Duration::from_secs(3)), selected, "proxy test").unwrap();
|
||||
assert!(execute_health_probe(None, client, request).await.unwrap().first_token_latency_ms.is_some());
|
||||
let wire_request = server.await.unwrap();
|
||||
assert!(wire_request.starts_with("POST http://provider.invalid/v1beta/models/test:streamGenerateContent?alt=sse HTTP/1.1"));
|
||||
}
|
||||
|
||||
async fn mock_core_response(
|
||||
status: &str,
|
||||
content_type: &str,
|
||||
|
||||
@@ -697,6 +697,85 @@ fn v7_config_startup_with_v8_template_writes_v8_version() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v8_migration_preserves_each_legacy_provider_family_independently() {
|
||||
let template = "config-version: 8\napi-keys: {codex: [], claude: []}\n";
|
||||
for marker in ["", "config-version: 7\n"] {
|
||||
let old = format!("{marker}api-keys: {{codex: []}}\ncodex-api-key: [{{api-key: ignored}}]\nclaude-api-key: [{{api-key: fixture, models: [{{name: upstream, alias: claude-alias}}]}}]\n");
|
||||
let migrated = migrate_legacy_core_config_to_v8(template, &old).unwrap();
|
||||
let value: serde_norway::Value = serde_norway::from_str(&migrated).unwrap();
|
||||
assert_eq!(value["api-keys"]["codex"].as_sequence().unwrap().len(), 0);
|
||||
assert_eq!(value["api-keys"]["claude"][0]["keys"][0]["api-key"], "fixture");
|
||||
assert_eq!(value["api-keys"]["claude"][0]["models"][0]["alias"], "claude-alias");
|
||||
assert!(value.get("claude-api-key").is_none());
|
||||
assert!(value.get("codex-api-key").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v8_migration_preserves_narrow_fields_and_canonical_struct_leaves() {
|
||||
let template = "config-version: 8\nmanagement: {allow-remote: false}\noauth: {providers: {claude: {model-level-cooling: false}, codex: {response-steering: false}}}\n";
|
||||
let legacy = r#"
|
||||
remote-management: {allow-remote: true, secret-key: old-secret}
|
||||
management: {secret-key: canonical-secret}
|
||||
claude-header-defaults: {user-agent: custom-agent, timezone: custom-zone}
|
||||
claude-code: {disable-cloaking-model-list: true}
|
||||
disable-claude-cloak-mode: true
|
||||
claude: {model-level-cooling: true}
|
||||
codex-header-defaults: {user-agent: codex-agent}
|
||||
codex: {response-steering: true, optimize-multi-agent-v2: true}
|
||||
oauth:
|
||||
providers:
|
||||
claude:
|
||||
model-level-cooling: false
|
||||
header-defaults: {timezone: canonical-zone}
|
||||
codex: {response-steering: false}
|
||||
"#;
|
||||
let migrated = migrate_legacy_core_config_to_v8(template, legacy).unwrap();
|
||||
let value: serde_norway::Value = serde_norway::from_str(&migrated).unwrap();
|
||||
let claude = &value["oauth"]["providers"]["claude"];
|
||||
assert_eq!(claude["header-defaults"]["user-agent"], "custom-agent");
|
||||
assert_eq!(claude["header-defaults"]["timezone"], "canonical-zone");
|
||||
assert_eq!(claude["model-level-cooling"].as_bool(), Some(false));
|
||||
assert_eq!(claude["claude-code"]["disable-cloaking-model-list"].as_bool(), Some(true));
|
||||
assert_eq!(claude["disable-claude-cloak-mode"].as_bool(), Some(true));
|
||||
assert_eq!(value["management"]["allow-remote"].as_bool(), Some(true));
|
||||
assert_eq!(value["management"]["secret-key"], "canonical-secret");
|
||||
assert_eq!(value["oauth"]["providers"]["codex"]["header-defaults"]["user-agent"], "codex-agent");
|
||||
assert_eq!(value["oauth"]["providers"]["codex"]["response-steering"].as_bool(), Some(false));
|
||||
assert!(value["oauth"]["providers"]["codex"].get("optimize-multi-agent-v2").is_none());
|
||||
assert_eq!(value["client"]["codex"]["optimize-multi-agent-v2"].as_bool(), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v8_migration_preserves_network_settings_over_template_defaults() {
|
||||
let template = "config-version: 8\nserver: {trusted-proxies: [], discovery: {enabled: false}, tls: {enable: false, cert: '', key: ''}}\n";
|
||||
let legacy = "trusted-proxies: [127.0.0.1]\ndiscovery: {enabled: true, service-name: office}\ntls: {enable: true, cert: old-cert, key: old-key}\nserver: {tls: {cert: canonical-cert}}\n";
|
||||
let migrated = migrate_legacy_core_config_to_v8(template, legacy).unwrap();
|
||||
let value: serde_norway::Value = serde_norway::from_str(&migrated).unwrap();
|
||||
assert_eq!(value["server"]["trusted-proxies"][0], "127.0.0.1");
|
||||
assert_eq!(value["server"]["discovery"]["enabled"].as_bool(), Some(true));
|
||||
assert_eq!(value["server"]["discovery"]["service-name"], "office");
|
||||
assert_eq!(value["server"]["tls"]["enable"].as_bool(), Some(true));
|
||||
assert_eq!(value["server"]["tls"]["cert"], "canonical-cert");
|
||||
assert_eq!(value["server"]["tls"]["key"], "old-key");
|
||||
assert!(value.get("trusted-proxies").is_none());
|
||||
assert!(value.get("discovery").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v8_migration_preserves_explicit_canonical_clears_and_existing_v8_files() {
|
||||
let template = "config-version: 8\nserver: {trusted-proxies: [192.0.2.1]}\n";
|
||||
let old = "trusted-proxies: [127.0.0.1]\nserver: {trusted-proxies: []}\nclaude-header-defaults: {user-agent: legacy}\nclaude: {model-level-cooling: true}\noauth: {providers: {claude: null}, model-alias: {}}\noauth-model-alias: {claude: [{name: original, alias: old-alias}]}\n";
|
||||
let migrated = migrate_legacy_core_config_to_v8(template, old).unwrap();
|
||||
let value: serde_norway::Value = serde_norway::from_str(&migrated).unwrap();
|
||||
assert!(value["server"]["trusted-proxies"].as_sequence().unwrap().is_empty());
|
||||
assert!(value["oauth"]["providers"]["claude"].is_null());
|
||||
assert!(value["oauth"]["model-alias"].as_mapping().unwrap().is_empty());
|
||||
let already_v8 = format!("config-version: 8\n{old}");
|
||||
assert_eq!(migrate_legacy_core_config_to_v8(template, &already_v8).unwrap(), already_v8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_a_core_rejects_invalid_config_without_overwriting_files() {
|
||||
let root = agent_test_home("core-config-migrate-invalid");
|
||||
|
||||
@@ -30,7 +30,7 @@ export function AuthFileSettingsDialog({ name, onClose, onSaved }: {
|
||||
const [error, setError] = useState('');
|
||||
const [discard, setDiscard] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const dirty = draft !== null && JSON.stringify(draft) !== JSON.stringify(original);
|
||||
const dirty = draft !== null && (Boolean(draft.normalizeCloakMetadata?.length) || JSON.stringify(draft) !== JSON.stringify(original));
|
||||
|
||||
useEffect(() => {
|
||||
const previousFocus = document.activeElement;
|
||||
|
||||
@@ -1011,6 +1011,12 @@ export const buildProviderKeyRecord = (
|
||||
return next;
|
||||
};
|
||||
|
||||
export const providerDraftConnection = (
|
||||
draft: Pick<ProviderDraft, 'headersText' | 'proxyUrl'>, key?: ProviderKeyDraft,
|
||||
) => effectiveProviderKey({
|
||||
headers: parseProviderHeaders(draft.headersText ?? ''), 'proxy-url': draft.proxyUrl,
|
||||
}, key ? serializeProviderKey(key) : {});
|
||||
|
||||
export const buildProviderGroupRecord = (
|
||||
section: ProviderSection, draft: ProviderDraft, current?: Record<string, unknown>,
|
||||
): Record<string, unknown> => {
|
||||
@@ -1437,6 +1443,8 @@ export function ApiAccessPage() {
|
||||
preparedDraft.groupKeys?.[0]
|
||||
? providerHeadersFromRecord(effectiveProviderKey({ headers: providerHeaders }, serializeProviderKey(preparedDraft.groupKeys[0])))
|
||||
: providerHeaders,
|
||||
undefined,
|
||||
readString(providerDraftConnection(draftToSave, preparedDraft.groupKeys?.[0]), 'proxy-url'),
|
||||
);
|
||||
if (fetchedModels.length === 0) {
|
||||
throw new Error(t('apiAccess.error.noModels'));
|
||||
@@ -1765,6 +1773,7 @@ function ProviderHealthDialog({ row, onClose, keySelector }: ProviderHealthDialo
|
||||
apiKeys: row.apiKeys,
|
||||
authIndex: row.authIndex,
|
||||
customHeaders: providerHeadersFromRecord(row.record),
|
||||
proxyUrl: readString(row.record, 'proxy-url'),
|
||||
timeoutMs: PROVIDER_HEALTH_TIMEOUT_MS,
|
||||
}), [row]);
|
||||
|
||||
@@ -1779,6 +1788,7 @@ function ProviderHealthDialog({ row, onClose, keySelector }: ProviderHealthDialo
|
||||
healthOptions.authIndex,
|
||||
healthOptions.customHeaders,
|
||||
healthOptions.timeoutMs,
|
||||
healthOptions.proxyUrl,
|
||||
).then((discovered) => {
|
||||
if (!disposed) setModels(mergeProviderHealthModels(discovered, row.models));
|
||||
}).catch((requestError) => {
|
||||
@@ -2118,7 +2128,7 @@ export function ApiProviderDialog({
|
||||
try {
|
||||
const provider: ModelProvider = activeCategory === 'deepseek' ? 'deepseek' : providerModelType(definition.section);
|
||||
const selectedKey = draft.groupKeys?.find((key) => key.id === discoveryKeyId) ?? draft.groupKeys?.[0];
|
||||
const connection = selectedKey ? effectiveProviderKey({ headers: parseProviderHeaders(draft.headersText ?? '') }, serializeProviderKey(selectedKey)) : null;
|
||||
const connection = selectedKey ? providerDraftConnection(draft, selectedKey) : null;
|
||||
const modelApiKey = connection ? readString(connection, 'api-key') : draft.apiKey.split(/\r?\n/).map((value) => value.trim()).find(Boolean) ?? '';
|
||||
const fetchedModels = await fetchModels(
|
||||
provider,
|
||||
@@ -2126,6 +2136,8 @@ export function ApiProviderDialog({
|
||||
modelApiKey,
|
||||
connection ? readString(selectedKey!.value, 'auth-index', 'authIndex') || undefined : editingRow?.authIndex,
|
||||
connection ? providerHeadersFromRecord(connection) : parseProviderHeaders(draft.headersText ?? ''),
|
||||
undefined,
|
||||
connection ? readString(connection, 'proxy-url') : draft.proxyUrl,
|
||||
);
|
||||
if (requestId !== discoveryRequestRef.current) return;
|
||||
const models = applyProviderPreset(
|
||||
|
||||
@@ -20,6 +20,7 @@ export type AuthFileSettingsDraft = {
|
||||
headers: string;
|
||||
note: string;
|
||||
advanced: Record<string, unknown>;
|
||||
normalizeCloakMetadata?: string[];
|
||||
};
|
||||
|
||||
const fail = (key: 'metadata' | 'headers' | 'weight' | 'priority') => {
|
||||
@@ -52,6 +53,27 @@ const headersFromText = (text: string): Record<string, string> => {
|
||||
return result;
|
||||
};
|
||||
|
||||
// OAuth cloak metadata is consumed as strings by the core, unlike the typed
|
||||
// cloak block in API-key configuration. Keep typed values only in the form.
|
||||
const advancedDraftValue = (key: string, value: unknown): unknown => {
|
||||
if (value == null) return value;
|
||||
if (key === 'cloak_strict_mode' || key === 'cloak_cache_user_id') {
|
||||
if (typeof value === 'string') return value.trim().toLowerCase() === 'true';
|
||||
}
|
||||
if (key === 'cloak_sensitive_words' && typeof value === 'string') {
|
||||
return value.split(',').map(word => word.trim()).filter(Boolean);
|
||||
}
|
||||
// Accept typed values persisted by older versions of this editor as well.
|
||||
return value;
|
||||
};
|
||||
|
||||
const advancedMetadataValue = (key: string, value: unknown): unknown => {
|
||||
if (value == null) return null;
|
||||
if (key === 'cloak_strict_mode' || key === 'cloak_cache_user_id') return String(value);
|
||||
if (key === 'cloak_sensitive_words') return (value as string[]).map(word => word.trim()).filter(Boolean).join(',');
|
||||
return value;
|
||||
};
|
||||
|
||||
export const authFileSettingsFromPayload = (payload: unknown): AuthFileSettingsDraft => {
|
||||
let metadata = payload;
|
||||
if (typeof metadata === 'string') {
|
||||
@@ -63,7 +85,14 @@ export const authFileSettingsFromPayload = (payload: unknown): AuthFileSettingsD
|
||||
const text = (value: unknown) => typeof value === 'string' || typeof value === 'number' ? String(value) : '';
|
||||
const headers = metadata.headers == null ? {} : metadata.headers;
|
||||
if (!isRecord(headers) || Object.values(headers).some((value) => typeof value !== 'string')) return fail('headers');
|
||||
const normalizeCloakMetadata = ['cloak_strict_mode', 'cloak_cache_user_id', 'cloak_sensitive_words'].filter(key => {
|
||||
const value = read(key, key.replace(/_/g, '-'));
|
||||
return key === 'cloak_sensitive_words'
|
||||
? Array.isArray(value) && value.every(word => typeof word === 'string')
|
||||
: typeof value === 'boolean';
|
||||
});
|
||||
return {
|
||||
...(normalizeCloakMetadata.length ? { normalizeCloakMetadata } : {}),
|
||||
prefix: text(metadata.prefix),
|
||||
proxy_url: text(read('proxy_url', 'proxy-url')),
|
||||
priority: text(metadata.priority),
|
||||
@@ -75,7 +104,7 @@ export const authFileSettingsFromPayload = (payload: unknown): AuthFileSettingsD
|
||||
note: text(metadata.note),
|
||||
advanced: Object.fromEntries(Object.keys(credentialAdvancedShape.fields ?? {}).flatMap(key => {
|
||||
const value = read(key, key.replace(/_/g, '-'));
|
||||
return value === undefined ? [] : [[key, value]];
|
||||
return value === undefined ? [] : [[key, advancedDraftValue(key, value)]];
|
||||
})),
|
||||
};
|
||||
};
|
||||
@@ -120,10 +149,10 @@ export const buildAuthFileSettingsPatch = (
|
||||
}
|
||||
for (const [key, shape] of Object.entries(credentialAdvancedShape.fields ?? {})) {
|
||||
const next = draft.advanced[key];
|
||||
if (sameTemplateValue(next, original.advanced[key])) continue;
|
||||
if (sameTemplateValue(next, original.advanced[key]) && !original.normalizeCloakMetadata?.includes(key)) continue;
|
||||
const error = validateStructuredValueText(shape, next, key);
|
||||
if (error) throw new Error(templateText(error, getCurrentLocale()));
|
||||
patch[key] = next ?? null;
|
||||
patch[key] = advancedMetadataValue(key, next);
|
||||
}
|
||||
return patch;
|
||||
};
|
||||
|
||||
@@ -184,6 +184,7 @@ export async function fetchModels(
|
||||
authIndex?: string,
|
||||
customHeaders: Record<string, string> = {},
|
||||
timeoutMs?: number,
|
||||
proxyUrl?: string,
|
||||
): Promise<ModelOption[]> {
|
||||
const normalized = baseUrl.trim() ? normalizeBaseUrl(baseUrl) : '';
|
||||
const candidates = modelEndpointCandidates(provider, normalized);
|
||||
@@ -222,6 +223,7 @@ export async function fetchModels(
|
||||
if (pageToken) pageUrl.searchParams.set('pageToken', pageToken);
|
||||
const response = await managementApi.post<Record<string, unknown>>('/requests/api-call', {
|
||||
authIndex: authIndex?.trim() || undefined,
|
||||
proxy_url: proxyUrl?.trim() || undefined,
|
||||
method: 'GET',
|
||||
url: pageUrl.toString(),
|
||||
header: Object.keys(headers).length ? headers : undefined,
|
||||
@@ -258,6 +260,7 @@ export async function fetchModels(
|
||||
|
||||
if (provider === 'openai' && Object.keys(headers).length > 0) {
|
||||
const response = await managementApi.post<Record<string, unknown>>('/requests/api-call', {
|
||||
proxy_url: proxyUrl?.trim() || undefined,
|
||||
method: 'GET',
|
||||
url,
|
||||
}, { timeoutMs });
|
||||
@@ -272,6 +275,7 @@ export async function fetchModels(
|
||||
if (provider === 'openai' && Object.keys(headers).length > 0) {
|
||||
try {
|
||||
const response = await managementApi.post<Record<string, unknown>>('/requests/api-call', {
|
||||
proxy_url: proxyUrl?.trim() || undefined,
|
||||
method: 'GET',
|
||||
url,
|
||||
}, { timeoutMs });
|
||||
|
||||
@@ -35,6 +35,7 @@ export type ProviderHealthCheckOptions = {
|
||||
authIndex?: string;
|
||||
customHeaders?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
proxyUrl?: string;
|
||||
};
|
||||
|
||||
const defaultBaseUrl = defaultProviderBaseUrl;
|
||||
@@ -125,7 +126,7 @@ export function buildProviderHealthProbe(
|
||||
return {
|
||||
...metadata,
|
||||
url: provider === 'vertex' ? `${root}/v1/publishers/google/models/${encodeURIComponent(normalizedModel)}:streamGenerateContent?alt=sse`
|
||||
: `${root}/v1beta/models/${encodeURIComponent(normalizedModel)}:generateContent?alt=sse`,
|
||||
: `${root}/v1beta/models/${encodeURIComponent(normalizedModel)}:streamGenerateContent?alt=sse`,
|
||||
header: headers,
|
||||
protocol: 'gemini',
|
||||
data: JSON.stringify({
|
||||
@@ -203,6 +204,7 @@ export async function checkProviderHealthProbe(
|
||||
authIndex = '',
|
||||
customHeaders: Record<string, string> = {},
|
||||
timeoutMs = PROVIDER_HEALTH_TIMEOUT_MS,
|
||||
proxyUrl?: string,
|
||||
): Promise<ProviderHealthProbeResult> {
|
||||
try {
|
||||
const probe = buildProviderHealthProbe(
|
||||
@@ -233,6 +235,7 @@ export async function checkProviderHealthProbe(
|
||||
model: probe.model,
|
||||
source: probe.source,
|
||||
authIndex: probe.authIndex,
|
||||
proxyUrl: proxyUrl?.trim() || undefined,
|
||||
},
|
||||
});
|
||||
const firstTokenLatencyMs = Number.isFinite(response.firstTokenLatencyMs)
|
||||
@@ -268,6 +271,7 @@ export async function checkProviderModelHealth(
|
||||
options.authIndex,
|
||||
options.customHeaders,
|
||||
options.timeoutMs,
|
||||
options.proxyUrl,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
|
||||
@@ -88,9 +88,9 @@ describe('credential settings', () => {
|
||||
|
||||
it('edits template credential fields without resending tokens or unmodified metadata', () => {
|
||||
const original = authFileSettingsFromPayload({ access_token: 'never-copy', cloak_mode: 'auto', timezone: 'Asia/Tokyo',
|
||||
cloak_strict_mode: true, model_aliases: [{ name: 'upstream', alias: 'public', future_metadata: true }] });
|
||||
cloak_strict_mode: 'true', model_aliases: [{ name: 'upstream', alias: 'public', future_metadata: true }] });
|
||||
expect(buildAuthFileSettingsPatch(original, { ...original, advanced: { ...original.advanced, cloak_mode: 'always', cloak_strict_mode: false } }))
|
||||
.toEqual({ cloak_mode: 'always', cloak_strict_mode: false });
|
||||
.toEqual({ cloak_mode: 'always', cloak_strict_mode: 'false' });
|
||||
const next = { ...original.advanced }; delete next.timezone;
|
||||
expect(buildAuthFileSettingsPatch(original, { ...original, advanced: next })).toEqual({ timezone: null });
|
||||
expect(buildAuthFileSettingsPatch(original, { ...original, advanced: { ...original.advanced, model_aliases: [] } })).toEqual({ model_aliases: [] });
|
||||
@@ -99,4 +99,59 @@ describe('credential settings', () => {
|
||||
expect(() => buildAuthFileSettingsPatch(original, { ...original, advanced: { ...original.advanced, ...advanced } })).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('converts native string cloak metadata to typed controls and writes executor-compatible strings', () => {
|
||||
const original = authFileSettingsFromPayload({ cloak_strict_mode: 'true', cloak_cache_user_id: 'false',
|
||||
cloak_sensitive_words: ' first,second , ', custom_metadata: { keep: true } });
|
||||
expect(original.advanced).toEqual({ cloak_strict_mode: true, cloak_cache_user_id: false, cloak_sensitive_words: ['first', 'second'] });
|
||||
expect(buildAuthFileSettingsPatch(original, original)).toEqual({});
|
||||
const patch = buildAuthFileSettingsPatch(original, { ...original, advanced: {
|
||||
cloak_strict_mode: false, cloak_cache_user_id: true, cloak_sensitive_words: ['changed', ' second '],
|
||||
} });
|
||||
expect(patch).toEqual({ cloak_strict_mode: 'false', cloak_cache_user_id: 'true', cloak_sensitive_words: 'changed,second' });
|
||||
expect(authFileSettingsFromPayload(patch).advanced).toEqual({ cloak_strict_mode: false, cloak_cache_user_id: true,
|
||||
cloak_sensitive_words: ['changed', 'second'] });
|
||||
expect(buildAuthFileSettingsPatch(original, { ...original, advanced: { cloak_sensitive_words: [] } }))
|
||||
.toEqual({ cloak_strict_mode: null, cloak_cache_user_id: null, cloak_sensitive_words: '' });
|
||||
});
|
||||
|
||||
it('can edit older incorrectly typed cloak metadata without resending unrelated fields', () => {
|
||||
const original = authFileSettingsFromPayload({ cloak_strict_mode: false, cloak_cache_user_id: true,
|
||||
cloak_sensitive_words: ['old'], fingerprint_profile: 'claude-code-cli', model_aliases: [{ name: 'upstream', alias: 'public', extension: true }] });
|
||||
expect(original.advanced.cloak_strict_mode).toBe(false);
|
||||
expect(original.advanced.cloak_sensitive_words).toEqual(['old']);
|
||||
expect(buildAuthFileSettingsPatch(original, { ...original, advanced: { ...original.advanced,
|
||||
cloak_strict_mode: true, cloak_cache_user_id: false, cloak_sensitive_words: ['new'],
|
||||
} })).toEqual({ cloak_strict_mode: 'true', cloak_cache_user_id: 'false', cloak_sensitive_words: 'new' });
|
||||
expect(buildAuthFileSettingsPatch(original, { ...original, note: 'only note' })).toEqual({ note: 'only note',
|
||||
cloak_strict_mode: 'false', cloak_cache_user_id: 'true', cloak_sensitive_words: 'old' });
|
||||
const canonicalNull = authFileSettingsFromPayload({ cloak_strict_mode: null, 'cloak-strict-mode': 'true', cloak_sensitive_words: null });
|
||||
expect(canonicalNull.advanced).toEqual({ cloak_strict_mode: null, cloak_sensitive_words: null });
|
||||
expect(canonicalNull.normalizeCloakMetadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it('repairs previously typed cloak fields on an unchanged explicit save and stops after reload', async () => {
|
||||
const metadata = { cloak_strict_mode: true, cloak_cache_user_id: false, cloak_sensitive_words: [] as string[],
|
||||
access_token: 'retain', extension: { untouched: true } } as Record<string, unknown>;
|
||||
const writes: Record<string, unknown>[] = [];
|
||||
const api = { get: async () => metadata, patch: async (_path: string, body: Record<string, unknown>) => {
|
||||
const { name, ...patch } = body;
|
||||
writes.push(patch);
|
||||
Object.assign(metadata, patch);
|
||||
} };
|
||||
const original = await loadAuthFileSettings('old.json', api);
|
||||
expect(original.normalizeCloakMetadata).toEqual(['cloak_strict_mode', 'cloak_cache_user_id', 'cloak_sensitive_words']);
|
||||
expect(await saveAuthFileSettings('old.json', original, original, api)).toBe(true);
|
||||
expect(writes).toEqual([{ cloak_strict_mode: 'true', cloak_cache_user_id: 'false', cloak_sensitive_words: '' }]);
|
||||
expect(metadata.access_token).toBe('retain');
|
||||
expect(metadata.extension).toEqual({ untouched: true });
|
||||
const reloaded = await loadAuthFileSettings('old.json', api);
|
||||
expect(reloaded.normalizeCloakMetadata).toBeUndefined();
|
||||
expect(reloaded.advanced).toEqual(original.advanced);
|
||||
expect(await saveAuthFileSettings('old.json', reloaded, reloaded, api)).toBe(false);
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(buildAuthFileSettingsPatch(original, { ...original, advanced: {} })).toEqual({
|
||||
cloak_strict_mode: null, cloak_cache_user_id: null, cloak_sensitive_words: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -149,9 +149,18 @@ oauth: {auth-dir: ${JSON.stringify(join(work, 'auth'))}}
|
||||
it('round-trips template credential aliases and Claude settings without replacing token metadata', async () => {
|
||||
const fileName = 'template-claude.json';
|
||||
await managementApi.uploadAuthFile(new File([JSON.stringify({ type: 'claude', access_token: 'isolated-template-token',
|
||||
expired: '2099-01-01T00:00:00Z', custom_metadata: { keep: true } })], fileName, { type: 'application/json' }));
|
||||
expired: '2099-01-01T00:00:00Z', custom_metadata: { keep: true },
|
||||
cloak_strict_mode: true, cloak_cache_user_id: false, cloak_sensitive_words: ['Legacy'] })], fileName, { type: 'application/json' }));
|
||||
try {
|
||||
const legacy = await loadAuthFileSettings(fileName);
|
||||
expect(await saveAuthFileSettings(fileName, legacy, legacy)).toBe(true);
|
||||
const repaired = await managementApi.get<Record<string, unknown>>('/credentials/download', { name: fileName });
|
||||
expect(repaired.cloak_strict_mode).toBe('true');
|
||||
expect(repaired.cloak_cache_user_id).toBe('false');
|
||||
expect(repaired.cloak_sensitive_words).toBe('Legacy');
|
||||
const original = await loadAuthFileSettings(fileName);
|
||||
expect(original.normalizeCloakMetadata).toBeUndefined();
|
||||
expect(await saveAuthFileSettings(fileName, original, original)).toBe(false);
|
||||
const advanced = { cloak_mode: 'always', cloak_strict_mode: false, cloak_cache_user_id: true, cloak_sensitive_words: ['Word'],
|
||||
fingerprint_profile: 'claude-code-cli', timezone: 'Asia/Shanghai',
|
||||
model_aliases: [{ name: 'claude-template', alias: 'public-template', fork: false, 'display-name': 'Template model', 'force-mapping': true }] };
|
||||
@@ -159,6 +168,9 @@ oauth: {auth-dir: ${JSON.stringify(join(work, 'auth'))}}
|
||||
const loaded = await loadAuthFileSettings(fileName);
|
||||
expect(loaded.advanced).toEqual(advanced);
|
||||
const metadata = await managementApi.get<Record<string, unknown>>('/credentials/download', { name: fileName });
|
||||
expect(metadata.cloak_strict_mode).toBe('false');
|
||||
expect(metadata.cloak_cache_user_id).toBe('true');
|
||||
expect(metadata.cloak_sensitive_words).toBe('Word');
|
||||
expect(metadata.access_token).toBe('isolated-template-token');
|
||||
expect(metadata.custom_metadata).toEqual({ keep: true });
|
||||
await saveAuthFileSettings(fileName, loaded, { ...loaded, advanced: {} });
|
||||
|
||||
@@ -207,7 +207,7 @@ describe('API 接入健康检测', () => {
|
||||
);
|
||||
|
||||
expect(probe.url).toBe(
|
||||
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?alt=sse',
|
||||
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse',
|
||||
);
|
||||
expect(probe.header['x-goog-api-key']).toBe('gemini-key');
|
||||
expect(probe.protocol).toBe('gemini');
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { clearMocks, mockIPC } from '@tauri-apps/api/mocks';
|
||||
import { fetchModels } from '../src/services/modelService';
|
||||
import { checkProviderModelHealth } from '../src/services/providerHealthCheck';
|
||||
import { providerDraftConnection } from '../src/pages/ApiAccessPage';
|
||||
import { providerKeyDraft } from '../src/services/providerGroups';
|
||||
|
||||
describe('provider request proxy routing', () => {
|
||||
let originalWindow: PropertyDescriptor | undefined;
|
||||
beforeEach(() => {
|
||||
originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'window', { value: {}, writable: true, configurable: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
clearMocks();
|
||||
if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
});
|
||||
|
||||
it('uses the edited shared proxy unless a key supplies a non-null override', () => {
|
||||
const draft = { proxyUrl: 'socks5://127.0.0.1:19080', headersText: 'X-Team: new' };
|
||||
for (const override of [undefined, null, '', 'direct', 'http://127.0.0.1:18080']) {
|
||||
const key = providerKeyDraft({ 'api-key': 'test-key', ...(override === undefined ? {} : { 'proxy-url': override }) });
|
||||
const effective = providerDraftConnection(draft, key);
|
||||
expect(effective['proxy-url']).toBe(override == null ? draft.proxyUrl : override);
|
||||
expect(effective.headers).toEqual({ 'X-Team': 'new' });
|
||||
}
|
||||
});
|
||||
|
||||
for (const firstResult of ['http-error', 'transport-error'] as const) {
|
||||
it(`retains direct proxy selection on unauthenticated discovery fallback (${firstResult})`, async () => {
|
||||
const requests: Record<string, any>[] = [];
|
||||
mockIPC((_command, args) => {
|
||||
const request = args?.request as Record<string, any>;
|
||||
requests.push(request);
|
||||
if (requests.length === 1) {
|
||||
if (firstResult === 'transport-error') throw new Error('mock transport failure');
|
||||
return { status_code: 401, body: 'unauthorized' };
|
||||
}
|
||||
return { status_code: 200, body: JSON.stringify({ data: [{ id: 'test-model' }] }) };
|
||||
});
|
||||
await fetchModels('openai', 'https://provider.invalid/v1', 'test-key', undefined, {}, undefined, ' direct ');
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(requests.every(request => request.body.proxy_url === 'direct')).toBe(true);
|
||||
expect(requests[1].body.header).toBeUndefined();
|
||||
});
|
||||
}
|
||||
|
||||
it('sends proxy overrides on every Gemini model page and uses SSE for health checks', async () => {
|
||||
const requests: Record<string, any>[] = [];
|
||||
mockIPC((command, args) => {
|
||||
const request = args?.request as Record<string, any>;
|
||||
requests.push(request);
|
||||
if (command === 'provider_health_probe') return { firstTokenLatencyMs: 12, responseLatencyMs: 12 };
|
||||
return { status_code: 200, body: { models: [{ name: `models/model-${requests.length}` }], ...(requests.length === 1 ? { nextPageToken: 'next' } : {}) } };
|
||||
});
|
||||
const proxyUrl = 'socks5://127.0.0.1:19080';
|
||||
await fetchModels('gemini', 'https://generativelanguage.googleapis.com', 'test-key', undefined, {}, undefined, proxyUrl);
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(requests.every(request => request.body.proxy_url === proxyUrl)).toBe(true);
|
||||
const result = await checkProviderModelHealth({ provider: 'gemini', baseUrl: '', apiKeys: ['test-key'], proxyUrl }, 'model-1');
|
||||
expect(result.success).toBe(true);
|
||||
expect(requests[2].proxyUrl).toBe(proxyUrl);
|
||||
expect(requests[2].url).toEndWith(':streamGenerateContent?alt=sse');
|
||||
});
|
||||
|
||||
it('leaves proxy inheritance unset rather than converting it to direct', async () => {
|
||||
mockIPC((command, args) => {
|
||||
const request = args?.request as Record<string, any>;
|
||||
if (command === 'provider_health_probe') {
|
||||
expect(request.proxyUrl).toBeUndefined();
|
||||
return { firstTokenLatencyMs: 12, responseLatencyMs: 12 };
|
||||
}
|
||||
expect(request.body.proxy_url).toBeUndefined();
|
||||
return { status_code: 200, body: { data: [{ id: 'model' }] } };
|
||||
});
|
||||
await fetchModels('openai', 'https://provider.invalid/v1', 'test-key');
|
||||
await checkProviderModelHealth({ provider: 'openai', baseUrl: 'https://provider.invalid/v1', apiKeys: ['test-key'], proxyUrl: '' }, 'model');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user