feat(config): support CLIProxyAPI v8.0.10 template settings

- Add structured editors for request, OAuth, plugin, and concurrency settings
- Expand advanced configuration for all eight provider families and credentials
- Preserve YAML comments, secrets, unknown fields, and explicit overrides
- Add validation, conflict detection, default restoration, and restart notices
- Improve responsive layouts and localize configuration controls
- Fix provider health checks and extend mocks and integration tests
This commit is contained in:
lzt404 committed 2026-10-04 17:31:52 +08:00
1 parent 4d1afc82ef
commit c89e2fb020
46 files changed
+3839 -235

No files matched your search

+2
View File
@@ -5,6 +5,7 @@ mod alias_edit;
mod alias_save;
mod aliases;
mod commands;
mod extended;
mod provider_groups;
mod settings;
mod yaml;
@@ -12,6 +13,7 @@ pub(crate) use alias_edit::*;
pub(crate) use alias_save::*;
pub(crate) use aliases::*;
pub(crate) use commands::*;
pub(crate) use extended::*;
pub(crate) use provider_groups::*;
pub(crate) use settings::*;
pub(crate) use yaml::*;
+11 -1
View File
@@ -12,7 +12,7 @@ pub(crate) struct OAuthAliasChannel {
pub(crate) force_mapping: bool,
}
pub(crate) const OAUTH_ALIAS_CHANNELS: [OAuthAliasChannel; 8] = [
pub(crate) const OAUTH_ALIAS_CHANNELS: [OAuthAliasChannel; 9] = [
OAuthAliasChannel {
key: "vertex",
provider: "Vertex OAuth",
@@ -85,6 +85,15 @@ pub(crate) const OAUTH_ALIAS_CHANNELS: [OAuthAliasChannel; 8] = [
supports_fast: false,
force_mapping: false,
},
OAuthAliasChannel {
key: "meta",
provider: "Meta OAuth",
kind: "meta-oauth",
protocol: "openai",
supports_reasoning: false,
supports_fast: false,
force_mapping: false,
},
];
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -137,6 +146,7 @@ pub(crate) fn normalize_oauth_alias_channel(value: &str) -> Option<&'static str>
"kimi" | "moonshot" => Some("kimi"),
"devin" | "cognition" => Some("devin"),
"xai" | "x-ai" | "grok" => Some("xai"),
"meta" => Some("meta"),
_ => None,
}
}
+2 -2
View File
@@ -166,8 +166,8 @@ pub(crate) fn save_network_endpoint_settings(
return Err("Port must be between 1 and 65535".to_string());
}
let host = normalize_optional_config_string(settings.host, "Listen IP")?;
if host.parse::<IpAddr>().is_err() {
return Err("Listen IP must be a valid IPv4 or IPv6 address".to_string());
if !host.is_empty() && !host.eq_ignore_ascii_case("localhost") && host.parse::<IpAddr>().is_err() {
return Err("Listen address must be empty, localhost, or a valid IPv4 or IPv6 address".to_string());
}
let previous = gui_config_state.snapshot()?;
let mut next = previous.clone();
File diff suppressed because it is too large. Load diff
+26 -24
View File
@@ -26,7 +26,8 @@ pub(crate) fn validate_api_key_remark(remark: &str) -> Result<(), String> {
pub(crate) fn validate_api_access_provider_section(section: &str) -> Result<(), String> {
if matches!(
section,
"gemini-api-key" | "codex-api-key" | "claude-api-key" | "openai-compatibility"
"gemini-api-key" | "interactions-api-key" | "vertex-api-key" | "codex-api-key"
| "claude-api-key" | "xai-api-key" | "meta-api-key" | "openai-compatibility"
) {
Ok(())
} else {
@@ -44,6 +45,10 @@ pub(crate) fn usage_provider_section(provider: &str) -> Option<&'static str> {
"codex" => Some("codex-api-key"),
"claude" => Some("claude-api-key"),
"gemini" | "aistudio" => Some("gemini-api-key"),
"interactions" => Some("interactions-api-key"),
"vertex" | "vertex-ai" => Some("vertex-api-key"),
"xai" | "grok" => Some("xai-api-key"),
"meta" => Some("meta-api-key"),
"openai" | "openai-compatibility" => Some("openai-compatibility"),
_ => None,
}
@@ -113,7 +118,8 @@ pub(crate) fn generate_management_secret_key() -> Result<String, String> {
pub(crate) fn management_secret_requires_rotation(secret_key: &str) -> bool {
let secret_key = secret_key.trim();
secret_key.is_empty() || is_hashed_management_secret_key(secret_key)
// Empty is an intentional disabled Management API, including after restart.
is_hashed_management_secret_key(secret_key)
}
pub(crate) fn ensure_strong_management_secret(config: &mut GuiConfigFile) -> Result<bool, String> {
@@ -150,10 +156,10 @@ pub(crate) fn is_hashed_management_secret_key(secret_key: &str) -> bool {
}
pub(crate) fn validate_routing_strategy(strategy: &str) -> Result<(), String> {
if matches!(strategy, "round-robin" | "fill-first") {
if matches!(strategy, "round-robin" | "weighted-round-robin" | "fill-first") {
return Ok(());
}
Err("Routing strategy supports only round-robin or fill-first".to_string())
Err("Routing strategy supports round-robin, weighted-round-robin, or fill-first".to_string())
}
pub(crate) fn normalize_optional_config_string(
@@ -1423,8 +1429,7 @@ pub(crate) fn load_or_create_gui_config() -> Result<GuiConfigFile, String> {
config.management_secret_key = core_settings
.management_secret_key
.as_deref()
.filter(|secret_key| !is_hashed_management_secret_key(secret_key))
.unwrap_or_default()
.unwrap_or(&config.management_secret_key)
.to_string();
}
if presence.routing_strategy.is_none() {
@@ -1464,8 +1469,7 @@ pub(crate) fn load_or_create_gui_config() -> Result<GuiConfigFile, String> {
config.management_secret_key = read_installed_core_config_settings()
.ok()
.and_then(|settings| settings.management_secret_key)
.filter(|secret_key| !is_hashed_management_secret_key(secret_key))
.unwrap_or_default();
.unwrap_or_else(|| config.management_secret_key.clone());
changed = true;
}
if presence.host.is_none() || presence.auth_dir.is_none() {
@@ -1783,15 +1787,6 @@ pub(crate) fn sanitize_gui_config_at(
changed = true;
}
let host = config.host.trim();
let host = if host.is_empty() {
if config.allow_lan {
"0.0.0.0"
} else {
"127.0.0.1"
}
} else {
host
};
if config.host != host {
config.host = host.to_string();
changed = true;
@@ -1801,14 +1796,16 @@ pub(crate) fn sanitize_gui_config_at(
config.allow_lan = allow_lan;
changed = true;
}
match migrate_auth_dir_at(config, install_dir, persistent_auth_dir) {
Ok(migrated) => changed |= migrated,
Err(error) => {
eprintln!("Failed to recover OAuth directory; keeping existing configuration: {error}");
if !config.auth_dir_user_selected {
match migrate_auth_dir_at(config, install_dir, persistent_auth_dir) {
Ok(migrated) => changed |= migrated,
Err(error) => {
eprintln!("Failed to recover OAuth directory; keeping existing configuration: {error}");
}
}
}
if config.auth_dir.trim().is_empty()
|| Path::new(config.auth_dir.trim()) == persistent_auth_dir
|| (!config.auth_dir_user_selected && Path::new(config.auth_dir.trim()) == persistent_auth_dir)
{
config.auth_dir = DEFAULT_AUTH_DIR.to_string();
changed = true;
@@ -1934,6 +1931,7 @@ pub(crate) fn write_gui_config_to_path(
("close-behavior", value(config.close_behavior.as_str())),
("default-terminal", value(config.default_terminal.as_str())),
("auth-dir", value(config.auth_dir.as_str())),
("auth-dir-user-selected", value(config.auth_dir_user_selected)),
(
"management-secret-key",
value(config.management_secret_key.as_str()),
@@ -2066,7 +2064,7 @@ pub(crate) fn validate_gui_config(config: &GuiConfigFile) -> Result<(), String>
if config.port == 0 {
return Err("GUI configuration port must be between 1 and 65535".to_string());
}
if config.host.trim().is_empty() || config.host.chars().any(char::is_control) {
if config.host.chars().any(char::is_control) {
return Err("Invalid GUI configuration host".to_string());
}
if config.auth_dir.trim().is_empty() || config.auth_dir.chars().any(char::is_control) {
@@ -2104,7 +2102,11 @@ pub(crate) fn validate_gui_config(config: &GuiConfigFile) -> Result<(), String>
}
validate_api_key_remark(&entry.remark)?;
}
validate_strong_management_secret_key(&config.management_secret_key)?;
if config.management_secret_key.is_empty() {
validate_management_secret_key(&config.management_secret_key)?;
} else {
validate_strong_management_secret_key(&config.management_secret_key)?;
}
validate_routing_strategy(config.routing_strategy.trim())?;
if config.proxy_override {
network_proxy::normalize_optional_proxy_url(&config.proxy_url)?;
+3 -7
View File
@@ -1468,7 +1468,7 @@ pub(crate) fn core_config_settings_from_value(
.ok_or_else(|| "auth-dir must be a string".to_string())
})
.transpose()?
.unwrap_or_else(|| OAUTH_DIR_NAME.to_string());
.unwrap_or_else(|| "~/.cli-proxy-api".to_string());
let debug = v8_or_legacy(&["observability", "logs", "debug"], &["debug"])
.map(|value| {
value
@@ -1622,9 +1622,8 @@ pub(crate) fn core_config_settings_from_value(
let max_retry_interval = v8_or_legacy(&["routing", "retry", "max-retry-interval"], &["max-retry-interval"])
.map(|value| {
value
.as_u64()
.and_then(|value| u32::try_from(value).ok())
.ok_or_else(|| "max-retry-interval must be a non-negative integer".to_string())
.as_i64()
.ok_or_else(|| "max-retry-interval must be an integer".to_string())
})
.transpose()?
.unwrap_or(0);
@@ -1743,9 +1742,6 @@ pub(crate) fn extract_core_management_secret_key(
.ok_or_else(|| "remote-management.secret-key must be a string".to_string())?
.trim()
.to_string();
if value.is_empty() {
return Ok(None);
}
Ok(Some(value))
}
+11 -8
View File
@@ -154,7 +154,7 @@ const DEFAULT_AGENT_TERMINAL: &str = "auto";
const DEFAULT_API_KEY_INITIAL_REMARK: &str = "Default key";
const DEFAULT_REQUEST_RETRY: u32 = 3;
const DEFAULT_MAX_RETRY_CREDENTIALS: u32 = 0;
const DEFAULT_MAX_RETRY_INTERVAL: u32 = 30;
const DEFAULT_MAX_RETRY_INTERVAL: i64 = 30;
const DEFAULT_STREAMING_BOOTSTRAP_RETRIES: u32 = 0;
const DEFAULT_DISABLE_COOLING: bool = false;
const DEFAULT_LOGS_MAX_TOTAL_SIZE_MB: u32 = 0;
@@ -603,6 +603,7 @@ struct GuiConfigFile {
window_width: Option<u32>,
window_height: Option<u32>,
auth_dir: String,
auth_dir_user_selected: bool,
#[serde(deserialize_with = "deserialize_gui_api_keys")]
api_keys: Vec<GuiApiKeyEntry>,
api_access_remarks: Vec<GuiApiAccessRemark>,
@@ -628,7 +629,7 @@ struct GuiConfigFile {
disable_cooling: bool,
request_retry: u32,
max_retry_credentials: u32,
max_retry_interval: u32,
max_retry_interval: i64,
streaming_bootstrap_retries: u32,
}
@@ -909,6 +910,7 @@ impl Default for GuiConfigFile {
window_width: Some(DEFAULT_MAIN_WINDOW_WIDTH),
window_height: Some(DEFAULT_MAIN_WINDOW_HEIGHT),
auth_dir: DEFAULT_AUTH_DIR.to_string(),
auth_dir_user_selected: false,
api_keys: vec![default_api_key_entry()],
api_access_remarks: Vec::new(),
management_secret_key: LEGACY_DEFAULT_MANAGEMENT_SECRET_KEY.to_string(),
@@ -975,7 +977,7 @@ struct GuiConfigPresence {
disable_cooling: Option<bool>,
request_retry: Option<u32>,
max_retry_credentials: Option<u32>,
max_retry_interval: Option<u32>,
max_retry_interval: Option<i64>,
streaming_bootstrap_retries: Option<u32>,
}
@@ -1467,7 +1469,7 @@ struct GuiNetworkRoutingSettings {
disable_cooling: bool,
request_retry: u32,
max_retry_credentials: u32,
max_retry_interval: u32,
max_retry_interval: i64,
streaming_bootstrap_retries: u32,
}
@@ -1489,7 +1491,7 @@ struct GuiRetrySettings {
disable_cooling: bool,
request_retry: u32,
max_retry_credentials: u32,
max_retry_interval: u32,
max_retry_interval: i64,
streaming_bootstrap_retries: u32,
}
@@ -1554,7 +1556,7 @@ struct CoreConfigSettings {
disable_cooling: bool,
request_retry: u32,
max_retry_credentials: u32,
max_retry_interval: u32,
max_retry_interval: i64,
streaming_bootstrap_retries: u32,
#[allow(dead_code)]
#[serde(skip_serializing)]
@@ -1593,7 +1595,7 @@ struct CoreConfigView {
disable_cooling: bool,
request_retry: u32,
max_retry_credentials: u32,
max_retry_interval: u32,
max_retry_interval: i64,
streaming_bootstrap_retries: u32,
}
@@ -2208,7 +2210,6 @@ impl GuiConfigState {
if let Some(secret_key) = settings
.management_secret_key
.as_deref()
.filter(|secret_key| !secret_key.is_empty())
.filter(|secret_key| !is_hashed_management_secret_key(secret_key))
{
config.management_secret_key = secret_key.to_string();
@@ -2675,6 +2676,8 @@ fn main() {
get_core_sensitive_words_settings,
save_core_sensitive_words_settings,
get_core_config_settings,
get_extended_core_config,
save_extended_core_config,
save_core_logging_settings,
set_core_request_log,
add_core_api_key,
+21 -2
View File
@@ -19,6 +19,8 @@ pub(crate) struct ProviderHealthProbeRequest {
header: HashMap<String, String>,
data: String,
protocol: String,
#[serde(default)]
source_provider: Option<String>,
timeout_ms: Option<u64>,
#[serde(default)]
model: String,
@@ -125,6 +127,15 @@ fn provider_health_json_has_text(protocol: &str, value: &serde_json::Value) -> b
})
})
}),
"interactions" => {
let event = value.get("event_type").or_else(|| value.get("type")).and_then(serde_json::Value::as_str);
(event == Some("content.delta")
&& value.pointer("/delta/type").and_then(serde_json::Value::as_str) == Some("text")
&& provider_health_value_has_text(value.pointer("/delta/text")))
|| (event == Some("interaction.complete")
&& value.pointer("/interaction/status").and_then(serde_json::Value::as_str) == Some("completed")
&& provider_health_value_has_text(value.pointer("/interaction/outputs")))
}
_ => false,
}
}
@@ -144,6 +155,11 @@ pub(crate) fn provider_health_stream_has_text(protocol: &str, bytes: &[u8]) -> b
}
fn provider_health_json_has_terminal_success(protocol: &str, value: &serde_json::Value) -> bool {
if protocol == "interactions" {
let event = value.get("event_type").or_else(|| value.get("type")).and_then(serde_json::Value::as_str);
return event == Some("interaction.complete")
&& value.pointer("/interaction/status").and_then(serde_json::Value::as_str) == Some("completed");
}
if protocol != "gemini" {
return false;
}
@@ -238,6 +254,7 @@ fn provider_health_usage_provider(protocol: &str) -> &str {
"openai-chat" => "openai",
"claude" => "claude",
"gemini" => "gemini",
"interactions" => "interactions",
_ => "unknown",
}
}
@@ -258,7 +275,9 @@ fn persist_provider_health_success(
"source": request.source.as_str(),
"auth_index": request.auth_index.as_str(),
"failed": false,
"provider": provider_health_usage_provider(&request.protocol),
"provider": request.source_provider.as_deref()
.filter(|provider| matches!(*provider, "interactions" | "vertex" | "xai" | "meta"))
.unwrap_or_else(|| provider_health_usage_provider(&request.protocol)),
"model": request.model.as_str(),
"executor_type": "DesktopProviderHealthCheck",
"endpoint": endpoint,
@@ -296,7 +315,7 @@ pub(crate) async fn provider_health_probe(
}
if !matches!(
request.protocol.as_str(),
"openai-chat" | "openai-responses" | "claude" | "gemini"
"openai-chat" | "openai-responses" | "claude" | "gemini" | "interactions"
) {
return Err("Unsupported health check protocol".to_string());
}
+1 -1
View File
@@ -94,7 +94,7 @@ fn gui_config_defaults_are_stable() {
assert!(content.contains("[[api-keys]]"));
assert!(content.contains("key = \"123456\""));
assert!(content.contains("remark = \"Default key\""));
assert!(content.contains("management-secret-key = \"\""));
assert!(content.contains(&format!("management-secret-key = \"{LEGACY_DEFAULT_MANAGEMENT_SECRET_KEY}\"")));
assert!(content.contains("plugins-enabled = false"));
assert!(content.contains("routing-strategy = \"round-robin\""));
assert!(content.contains("download-source = \"github\""));
+56 -5
View File
@@ -116,7 +116,7 @@ fn webui_management_secret_requires_a_non_empty_plaintext_value() {
}
#[test]
fn management_secret_rotation_replaces_empty_values_and_preserves_custom_values() {
fn management_secret_rotation_preserves_disabled_and_custom_values() {
let fresh = GuiConfigFile::default();
assert_eq!(
fresh.management_secret_key,
@@ -130,9 +130,9 @@ fn management_secret_rotation_replaces_empty_values_and_preserves_custom_values(
management_secret_key: String::new(),
..GuiConfigFile::default()
};
assert!(ensure_strong_management_secret(&mut empty).unwrap());
assert!(empty.management_secret_key.starts_with("wui-Aa9_"));
assert!(empty.management_secret_key.len() >= 50);
assert!(!ensure_strong_management_secret(&mut empty).unwrap());
assert!(empty.management_secret_key.is_empty());
assert!(validate_gui_config(&empty).is_ok());
let mut legacy = GuiConfigFile {
management_secret_key: LEGACY_DEFAULT_MANAGEMENT_SECRET_KEY.to_string(),
@@ -207,6 +207,7 @@ fn default_auth_directory_is_relative_and_legacy_absolute_value_is_migrated() {
let mut config = GuiConfigFile {
auth_dir: path_to_string(&fixed_oauth_dir().unwrap()),
auth_dir_user_selected: false,
..GuiConfigFile::default()
};
assert!(sanitize_gui_config(&mut config).unwrap());
@@ -1886,7 +1887,7 @@ fn core_config_validates_keys_and_routing_strategy() {
assert!(validate_core_api_key("").is_err());
assert!(validate_core_api_key("contains space").is_err());
assert!(validate_routing_strategy("round-robin").is_ok());
assert!(validate_routing_strategy("weighted-round-robin").is_err());
assert!(validate_routing_strategy("weighted-round-robin").is_ok());
assert!(validate_routing_strategy("fill-first").is_ok());
assert!(validate_routing_strategy("random").is_err());
}
@@ -1949,6 +1950,7 @@ fn startup_preserves_all_user_owned_yaml_and_only_applies_gui_managed_values() {
window_width: None,
window_height: None,
auth_dir: path_to_string(&fixed_oauth_dir().unwrap()),
auth_dir_user_selected: false,
api_keys: vec![
default_api_key_entry(),
GuiApiKeyEntry {
@@ -2119,3 +2121,52 @@ fn startup_merge_can_shrink_template_api_key_sequence() {
config.management_secret_key
);
}
#[test]
fn v8_signed_retry_weighted_routing_and_empty_host_roundtrip() {
let content = "config-version: 8\nserver: {host: '', port: 8317}\nrouting: {strategy: weighted-round-robin, retry: {max-retry-interval: -1}}\nmanagement: {secret-key: ''}\n";
let settings = core_config_settings_from_value(&serde_norway::from_str(content).unwrap()).unwrap();
let mut config = GuiConfigFile::default();
apply_core_settings_to_gui_config(&mut config, &settings);
sanitize_gui_config(&mut config).unwrap();
validate_gui_config(&config).unwrap();
assert_eq!(config.host, "");
assert_eq!(config.max_retry_interval, -1);
assert_eq!(config.routing_strategy, "weighted-round-robin");
assert!(!ensure_strong_management_secret(&mut config).unwrap());
assert!(config.management_secret_key.is_empty());
let serialized = toml::to_string(&config).unwrap();
let mut restored: GuiConfigFile = toml::from_str(&serialized).unwrap();
assert!(!ensure_strong_management_secret(&mut restored).unwrap());
assert_eq!(restored.max_retry_interval, -1);
let saved = apply_gui_managed_settings(content, &restored).unwrap();
let document: serde_norway::Value = serde_norway::from_str(&saved).unwrap();
assert_eq!(document["server"]["host"], "");
assert_eq!(document["management"]["secret-key"], "");
assert_eq!(document["routing"]["retry"]["max-retry-interval"], -1);
}
#[test]
fn explicitly_selected_auth_directory_is_not_moved_on_restart() {
let root = agent_test_home("selected-auth-no-migration");
let install_dir = root.join("cpa-core");
let selected = install_dir.join("oauth");
let persistent = root.join("oauth");
fs::create_dir_all(&selected).unwrap();
fs::write(selected.join("account.json"), "credential").unwrap();
let mut config = GuiConfigFile { auth_dir: "oauth".into(), auth_dir_user_selected: true, ..GuiConfigFile::default() };
sanitize_gui_config_at(&mut config, &install_dir, &persistent).unwrap();
assert_eq!(config.auth_dir, "oauth");
assert!(selected.join("account.json").exists());
assert!(!persistent.exists());
let config_path = root.join("selected.toml");
write_gui_config_to_path(&config, &config_path).unwrap();
let mut restored: GuiConfigFile = toml::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap();
assert!(restored.auth_dir_user_selected);
sanitize_gui_config_at(&mut restored, &install_dir, &persistent).unwrap();
assert_eq!(restored.auth_dir, "oauth");
assert!(selected.join("account.json").exists());
fs::remove_dir_all(root).unwrap();
}
+1
View File
@@ -49,6 +49,7 @@ fn oauth_auth_provider_names_map_to_alias_channels() {
Some("aistudio")
);
assert_eq!(normalize_oauth_alias_channel("grok"), Some("xai"));
assert_eq!(normalize_oauth_alias_channel("meta"), Some("meta"));
}
#[test]
+15
View File
@@ -97,3 +97,18 @@ data: {"delta":{"type":"text_delta","text":"H"}}
gemini_thought_only
));
}
#[test]
fn interactions_health_probe_recognizes_content_and_completed_events() {
assert!(provider_health_stream_has_text("interactions", br#"data: {"event_type":"content.delta","delta":{"type":"text","text":"Hello"}}
"#));
assert!(provider_health_stream_has_text("interactions", br#"data: {"event_type":"interaction.complete","interaction":{"status":"completed","outputs":[{"type":"text","text":"Hello"}]}}
"#));
assert!(provider_health_stream_has_terminal_success("interactions", br#"data: {"event_type":"interaction.complete","interaction":{"status":"completed"}}
"#));
assert!(!provider_health_stream_has_terminal_success("interactions", br#"data: {"event_type":"interaction.complete","interaction":{"status":"failed"}}
"#));
assert!(!provider_health_stream_has_text("interactions", br#"data: {"event_type":"content.delta","delta":{"type":"image","text":"metadata"}}
"#));
}
+1
View File
@@ -5133,6 +5133,7 @@ mod tests {
window_width: None,
window_height: None,
auth_dir: String::new(),
auth_dir_user_selected: false,
api_keys: Vec::new(),
api_access_remarks: Vec::new(),
management_secret_key: "123456".to_string(),
+10
View File
@@ -6,6 +6,8 @@ import { managementApi } from '../services/managementApi';
import { loadAuthFileSettings, saveAuthFileSettings, type AuthFileSettingsDraft, type BooleanOverride } from '../services/authFileSettings';
import { modelMatchesRule, normalizeOAuthExcludedRules, oauthModelCandidates, oauthModelsFromPayload, setOAuthModelsExcluded, type OAuthModelDefinition } from '../services/oauthModels';
import './AuthFileSettingsDialog.css';
import { StructuredConfigEditor, useConfigText } from './StructuredConfigEditor';
import { credentialAdvancedShape } from '../services/credentialAdvancedSettings';
export function AuthFileSettingsDialog({ name, onClose, onSaved }: {
name: string;
@@ -13,6 +15,7 @@ export function AuthFileSettingsDialog({ name, onClose, onSaved }: {
onSaved: () => void;
}) {
const { t } = useI18n();
const tx = useConfigText();
const id = useId();
const dialogRef = useRef<HTMLDialogElement>(null);
const savingRef = useRef(false);
@@ -166,6 +169,13 @@ export function AuthFileSettingsDialog({ name, onClose, onSaved }: {
</div>
</details>
</section>
<section className="credential-settings-section">
<details>
<summary>{tx({ zh: '模型别名与 Claude 兼容设置', en: 'Model aliases and Claude compatibility', ja: 'モデル別名と Claude 互換性' })}</summary>
<p><small>{tx({ zh: '这里的别名仅作用于此凭据,优先于全局别名。伪装与请求指纹适用于 Claude 及兼容委派凭据;未设置的字段继续使用默认行为。', en: 'Aliases apply only to this credential and take precedence over global aliases. Cloaking and fingerprints apply to Claude and compatible delegated credentials. Unset fields retain default behavior.', ja: '別名はこの認証情報にのみ適用され、全体の別名より優先されます。偽装とフィンガープリントは Claude と互換の委譲認証情報に適用します。' })}</small></p>
<StructuredConfigEditor shape={credentialAdvancedShape} value={draft.advanced} onChange={value => update('advanced', value as Record<string, unknown>)} disabled={saving} id={`${id}-advanced`} />
</details>
</section>
<section className="credential-settings-section">
<h3>{t('authFiles.settings.additional')}</h3>
<label className="credential-settings-field">
+9 -7
View File
@@ -5,6 +5,7 @@ import type { MessageKey } from '../i18n/resources';
import { isRecord, maskSecret, readString } from '../services/managementApi';
import { effectiveProviderKey, providerKeyDraft, providerKeyOverrides, type ProviderKeyDraft } from '../services/providerGroups';
import '../styles/provider-groups.css';
import { ProviderKeyTemplateFields, ProviderModelFields } from './ProviderTemplateFields';
type Props = {
keys: ProviderKeyDraft[];
@@ -60,10 +61,10 @@ export function ProviderGroupKeysEditor({ keys, shared, section, disabled, onCha
</div>
<details className="provider-key-settings">
<summary>{t('apiAccess.groups.keySettings')} · {overrideCount ? t('apiAccess.groups.overrides', { count: overrideCount }) : t('apiAccess.groups.inherits')}</summary>
<label><span>{t('apiAccess.groups.weight')}</span><input type="number" min="0" max="1000000" step="1" value={key.weight == null ? '' : String(key.weight)}
<label><span>{t('apiAccess.groups.weight')}</span><input type="number" max="1000000" step="1" value={key.weight == null ? '' : String(key.weight)}
placeholder="1" onChange={(event) => set(draft.id, 'weight', event.currentTarget.value === '' ? undefined : Number(event.currentTarget.value))} /></label>
<p className="provider-group-hint">{t('apiAccess.groups.overrideHint')}</p>
{fields.map(({ field, label, kind, initial }) => {
{fields.filter(({ field }) => section !== 'openai-compatibility' || field === 'proxy-url').map(({ field, label, kind, initial }) => {
const overridden = key[field] != null;
const content = draft.text?.[field] ?? (field === 'headers' && isRecord(key[field])
? Object.entries(key[field]).map(([name, value]) => `${name}: ${value}`).join('\n')
@@ -79,7 +80,7 @@ export function ProviderGroupKeysEditor({ keys, shared, section, disabled, onCha
onChange={(event) => set(draft.id, field, kind === 'number' ? Number(event.currentTarget.value) : event.currentTarget.value)} /> : null}
</div>;
})}
<div className="provider-key-override">
{section !== 'openai-compatibility' ? <div className="provider-key-override">
<label className="provider-key-override-toggle"><input type="checkbox" checked={key.models != null}
onChange={(event) => set(draft.id, 'models', event.currentTarget.checked ? structuredClone(effective.models ?? []) : undefined)} />
<span>{t('apiAccess.groups.overrideModels')}</span></label>
@@ -87,16 +88,17 @@ export function ProviderGroupKeysEditor({ keys, shared, section, disabled, onCha
{key.models.map((raw, modelIndex) => {
const model = isRecord(raw) ? raw : { name: String(raw) };
const change = (patch: Record<string, string>) => set(draft.id, 'models', (key.models as unknown[]).map((item, i) => i === modelIndex ? { ...model, ...patch } : item));
return <div className="model-config-entry" key={modelIndex}>
return <div className="provider-model-config" key={modelIndex}><div className="model-config-entry">
<input aria-label={t('apiAccess.models.namePlaceholder')} value={readString(model, 'name')} onChange={(event) => change({ name: event.currentTarget.value })} />
<input aria-label={t('apiAccess.models.aliasPlaceholder')} value={readString(model, 'alias')} onChange={(event) => change({ alias: event.currentTarget.value })} />
<button type="button" className="icon-button quiet danger" aria-label={t('apiAccess.models.remove')} onClick={() => set(draft.id, 'models', (key.models as unknown[]).filter((_, i) => i !== modelIndex))}><Trash2 size={14} /></button>
</div>;
</div><ProviderModelFields value={model} section={section} onChange={(next) => set(draft.id, 'models', (key.models as unknown[]).map((item, i) => i === modelIndex ? next : item))} /></div>;
})}
<button type="button" className="secondary-button compact-button" onClick={() => set(draft.id, 'models', [...key.models as unknown[], { name: '', alias: '' }])}><Plus size={14} />{t('apiAccess.models.add')}</button>
</div> : null}
</div>
{section === 'codex-api-key' ? <label><span>WebSocket</span><select value={key.websockets == null ? '' : String(key.websockets)} onChange={(event) => set(draft.id, 'websockets', event.currentTarget.value === '' ? undefined : event.currentTarget.value === 'true')}>
</div> : null}
<ProviderKeyTemplateFields value={key} section={section} inherited={shared} onChange={(value) => update(draft.id, (current) => ({ ...current, value }))} />
{section === 'codex-api-key' || section === 'xai-api-key' ? <label><span>WebSocket</span><select value={key.websockets == null ? '' : String(key.websockets)} onChange={(event) => set(draft.id, 'websockets', event.currentTarget.value === '' ? undefined : event.currentTarget.value === 'true')}>
<option value="">{t('apiAccess.option.inherit')}</option><option value="true">{t('common.enabled')}</option><option value="false">{t('common.disabled')}</option>
</select></label> : null}
{section === 'claude-api-key' ? <div className="provider-cloak-settings">
+135
View File
@@ -0,0 +1,135 @@
import type { ReactNode } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { useI18n } from '../i18n';
import { providerText, type ProviderTemplateMessage } from '../i18n/providerTemplate';
import { isRecord, readString } from '../services/managementApi';
import '../styles/provider-template.css';
type Value = Record<string, unknown>;
type Change = (value: Value) => void;
const changed = (value: Value, field: string, next: unknown): Value => {
const result = { ...value };
if (next === undefined) delete result[field]; else result[field] = next;
return result;
};
const lines = (value: unknown) => Array.isArray(value) ? value.map(String).join('\n') : '';
export function ProviderField({ value, onChange, field, label, kind = 'boolean', initial, hint, choices }: {
value: Value; onChange: Change; field: string; label: ProviderTemplateMessage;
kind?: 'boolean' | 'text' | 'number' | 'lines' | 'select'; initial?: unknown; hint?: ProviderTemplateMessage;
choices?: { value: string; label: string }[];
}) {
const { locale } = useI18n();
const p = (key: ProviderTemplateMessage) => providerText(key, locale);
const current = value[field];
const enabled = current != null;
const set = (next: unknown) => onChange(changed(value, field, next));
return <div className="provider-template-field">
{kind === 'boolean' ? <label><span>{p(label)}</span>
<select aria-label={p(label)} value={enabled ? String(current) : ''} onChange={(event) => set(event.currentTarget.value === '' ? undefined : event.currentTarget.value === 'true')}>
<option value="">{p('inherit')}</option><option value="true">{p('enabled')}</option><option value="false">{p('disabled')}</option>
</select></label> : <>
<label className="provider-template-toggle"><input type="checkbox" checked={enabled} onChange={(event) => set(event.currentTarget.checked
? structuredClone(initial ?? (kind === 'number' ? 0 : kind === 'lines' ? [] : '')) : undefined)} /><span>{p(label)}</span></label>
{enabled ? kind === 'lines' ? <textarea aria-label={p(label)} rows={3} value={lines(current)} onChange={(event) => set(event.currentTarget.value.split(/\r?\n/))} onBlur={(event) => set(event.currentTarget.value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean))} />
: kind === 'select' ? <select aria-label={p(label)} value={String(current)} onChange={(event) => set(event.currentTarget.value)}>
{!choices?.some((choice) => choice.value === current) ? <option value={String(current)}>{String(current)}</option> : null}
{choices?.map((choice) => <option key={choice.value} value={choice.value}>{choice.label}</option>)}
</select> : <input aria-label={p(label)} type={kind} step={kind === 'number' ? '1' : undefined} value={String(current)} onChange={(event) => set(kind === 'number' && event.currentTarget.value !== '' ? Number(event.currentTarget.value) : event.currentTarget.value)} /> : null}
</>}
{hint ? <small>{p(hint)}</small> : null}
</div>;
}
export function ProviderErrorRules({ value, onChange, inherited }: { value: Value; onChange: Change; inherited?: unknown }) {
const { locale } = useI18n();
const p = (key: ProviderTemplateMessage) => providerText(key, locale);
const field = 'request-scoped-errors';
const rules = Array.isArray(value[field]) ? value[field] as unknown[] : [];
const set = (next: unknown) => onChange(changed(value, field, next));
const actions = ['stop', 'stop-and-cooldown', 'continue', 'continue-and-cooldown'] as const;
return <details className="provider-template-section">
<summary>{p('errors')}{value[field] != null ? ` · ${rules.length}` : ''}</summary>
<p className="provider-group-hint">{p('errorsHint')}</p>
<label className="provider-template-toggle"><input type="checkbox" checked={value[field] != null}
onChange={(event) => set(event.currentTarget.checked ? structuredClone(inherited ?? []) : undefined)} /><span>{p('configure')}</span></label>
{value[field] != null ? <div className="provider-template-rules">
{rules.map((raw, index) => {
const rule = isRecord(raw) ? raw : {};
const update = (next: Value) => set(rules.map((item, i) => i === index ? next : item));
return <fieldset key={index} className="provider-template-rule"><legend>{index + 1}</legend>
<div className="provider-template-grid">
<label><span>{p('status')}</span><input aria-label={p('status')} type="number" min="100" max="599" step="1" value={String(rule.status ?? '')} onChange={(event) => update({ ...rule, status: event.currentTarget.value === '' ? '' : Number(event.currentTarget.value) })} /></label>
<label><span>{p('action')}</span><select aria-label={p('action')} value={readString(rule, 'action')} onChange={(event) => update({ ...rule, action: event.currentTarget.value })}>
{!actions.some((action) => action === rule.action) ? <option value={readString(rule, 'action')}>{readString(rule, 'action')}</option> : null}
{actions.map((action) => <option key={action} value={action}>{p(action)}</option>)}
</select></label>
<ProviderField value={rule} onChange={update} field="match" label="match" kind="lines" />
<ProviderField value={rule} onChange={update} field="match-regexr" label="regex" kind="lines" />
</div>
<button type="button" className="secondary-button compact-button" onClick={() => set(rules.filter((_, i) => i !== index))}><Trash2 size={14} />{p('remove')}</button>
</fieldset>;
})}
<button type="button" className="secondary-button compact-button" onClick={() => set([...rules, { status: 400, action: 'stop' }])}><Plus size={14} />{p('addRule')}</button>
</div> : null}
</details>;
}
export function ProviderGroupTemplateFields({ value, onChange, section }: { value: Value; onChange: Change; section: string }) {
return <div className="provider-template-fields">
<ProviderField value={value} onChange={onChange} field="request-retry" label="retry" kind="number" initial={0} hint="retryHint" />
{section === 'openai-compatibility' ? <ProviderField value={value} onChange={onChange} field="support-prompt-cache-key" label="promptCache" /> : null}
{section !== 'vertex-api-key' ? <ProviderErrorRules value={value} onChange={onChange} /> : null}
</div>;
}
export function ProviderKeyTemplateFields({ value, onChange, section, inherited }: { value: Value; onChange: Change; section: string; inherited: Value }) {
const { locale } = useI18n();
return <div className="provider-template-fields">
{!['vertex-api-key', 'openai-compatibility'].includes(section) ? <ProviderErrorRules value={value} onChange={onChange} inherited={inherited['request-scoped-errors']} /> : null}
{section === 'codex-api-key' ? <div className="provider-template-grid">
<ProviderField value={value} onChange={onChange} field="disable-codex-cloaking" label="noCloak" />
<ProviderField value={value} onChange={onChange} field="alpha-search" label="alpha" />
</div> : null}
{section === 'claude-api-key' ? <div className="provider-template-grid">
<ProviderField value={value} onChange={onChange} field="rebuild-mid-system-message" label="rebuild" />
<ProviderField value={value} onChange={onChange} field="fingerprint-profile" label="fingerprint" kind="select" choices={[
{ value: '', label: providerText('caller', locale) }, { value: 'claude-code-cli', label: 'Claude Code CLI' },
]} />
<ProviderField value={value} onChange={onChange} field="experimental-cch-signing" label="cch" hint="deprecated" />
</div> : null}
</div>;
}
export function ProviderModelFields({ value, onChange, section, children }: { value: Value; onChange: Change; section: string; children?: ReactNode }) {
const { locale } = useI18n();
const thinking = isRecord(value.thinking) ? value.thinking : {};
const setThinking = (next: Value) => onChange({ ...value, thinking: next });
return <details className="provider-template-section provider-model-options">
<summary>{providerText('options', locale)}</summary>
<p className="provider-group-hint">{providerText('modelHint', locale)}</p>
<div className="provider-template-grid">
<ProviderField value={value} onChange={onChange} field="display-name" label="display" kind="text" />
{section !== 'vertex-api-key' ? <>
<ProviderField value={value} onChange={onChange} field="max-context-length" label="context" kind="number" />
<ProviderField value={value} onChange={onChange} field="is-compat" label="compat" />
</> : null}
<ProviderField value={value} onChange={onChange} field="force-mapping" label="force" />
{['codex-api-key', 'xai-api-key', 'meta-api-key'].includes(section) ? <ProviderField value={value} onChange={onChange} field="support-configuration-update" label="configuration" /> : null}
{section === 'openai-compatibility' ? <>
<ProviderField value={value} onChange={onChange} field="image" label="image" hint="imageHint" />
<ProviderField value={value} onChange={onChange} field="input-modalities" label="input" kind="lines" hint="linesHint" />
<ProviderField value={value} onChange={onChange} field="output-modalities" label="output" kind="lines" hint="linesHint" />
<ProviderField value={value} onChange={onChange} field="use-max-completion-tokens" label="completion" />
</> : null}
</div>
<label className="provider-template-toggle"><input type="checkbox" checked={value.thinking != null} onChange={(event) => onChange(changed(value, 'thinking', event.currentTarget.checked ? {} : undefined))} /><span>{providerText('thinking', locale)}</span></label>
{value.thinking != null ? <div className="provider-template-grid">
<ProviderField value={thinking} onChange={setThinking} field="levels" label="levels" kind="lines" hint="linesHint" />
<ProviderField value={thinking} onChange={setThinking} field="min" label="min" kind="number" />
<ProviderField value={thinking} onChange={setThinking} field="max" label="max" kind="number" />
<ProviderField value={thinking} onChange={setThinking} field="zero-allowed" label="zero" />
<ProviderField value={thinking} onChange={setThinking} field="dynamic-allowed" label="dynamic" />
</div> : null}{children}
</details>;
}
+32
View File
@@ -0,0 +1,32 @@
.structured-list, .structured-map { display: grid; gap: 12px; min-width: 0; }
.structured-list-item, .structured-map-item { padding: 14px; border: 1px solid var(--border-color); border-radius: 12px; background: var(--bg-card); min-width: 0; }
.structured-list-toolbar, .structured-map-key, .structured-label { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; }
.structured-list-toolbar { margin-bottom: 10px; color: var(--text-tertiary); font-size: 12px; }
.structured-list-toolbar > div { display: flex; gap: 2px; }
.structured-map-key { margin-bottom: 12px; overflow-wrap: anywhere; }
.structured-object { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 14px; min-width: 0; }
.structured-field { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
.structured-field-wide { grid-column: 1 / -1; }
.structured-label { font-size: 13px; font-weight: 600; }
.structured-label label { overflow-wrap: anywhere; }
.structured-field small { font-size: 12px; color: var(--text-tertiary); line-height: 1.5; }
.structured-field .text-input, .structured-field .select-input, .structured-map-add .text-input, .structured-any .select-input, .structured-list-item > .text-input { width: 100%; min-width: 0; }
.structured-unset { justify-content: flex-start; color: var(--text-tertiary); white-space: normal; text-align: start; }
.structured-add { justify-self: start; }
.structured-map-add { display: flex; gap: 8px; flex-wrap: wrap; }
.structured-map-add .text-input { flex: 1 1 180px; width: auto; }
.structured-map-add .secondary-button { flex: 0 0 auto; }
.structured-any { display: grid; gap: 8px; min-width: 0; }
.structured-any > .select-input { max-width: 160px; }
.structured-error { color: var(--failure-badge-text); font-size: 12px; }
.structured-list .text-input, .structured-list .select-input,
.structured-map .text-input, .structured-map .select-input,
.structured-object .text-input, .structured-object .select-input,
.structured-any > .text-input, .structured-any > .select-input {
box-sizing: border-box; min-width: 0; width: 100%; padding: 9px 11px;
border: 1px solid var(--border-color); border-radius: 8px;
background: var(--bg-primary); color: var(--text-primary); font: inherit; font-size: 13px; line-height: 1.5;
}
.structured-list :is(input, select):focus-visible, .structured-map :is(input, select):focus-visible,
.structured-object :is(input, select):focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; }
.structured-list button, .structured-map button, .structured-object button { max-width: 100%; }
+116
View File
@@ -0,0 +1,116 @@
import { useId, useState } from 'react';
import { ArrowDown, ArrowUp, Plus, Trash2, Undo2 } from 'lucide-react';
import { useI18n } from '../i18n';
import { templateText } from '../i18n/templateConfig';
import { configRecord, defaultStructuredValue, type ConfigShape, type ConfigText } from '../services/structuredConfig';
import './StructuredConfigEditor.css';
export function useConfigText() {
const { locale } = useI18n();
return (value: ConfigText | undefined): string => {
if (!value) return '';
if (typeof value === 'string') return value;
return templateText(value, locale);
};
}
type Props = { shape: ConfigShape; value: unknown; onChange: (value: unknown) => void; disabled?: boolean; id?: string };
export function StructuredConfigEditor({ shape, value, onChange, disabled = false, id: providedId }: Props) {
const generatedId = useId();
const id = providedId ?? generatedId;
const tx = useConfigText();
const [newKey, setNewKey] = useState('');
const [keyError, setKeyError] = useState('');
const addText = tx({ zh: '添加', en: 'Add', ja: '追加' });
const removeText = tx({ zh: '删除', en: 'Remove', ja: '削除' });
const inheritText = tx({ zh: '继承 / 未设置', en: 'Inherit / not set', ja: '継承 / 未設定' });
const label = tx(shape.label) || id;
if (shape.type === 'any') {
const current = Array.isArray(value) ? 'array' : configRecord(value) ? 'map' : value === null ? 'null' : typeof value;
const types = {
string: { zh: '文本', en: 'Text', ja: 'テキスト' },
number: { zh: '数值', en: 'Number', ja: '数値' },
boolean: { zh: '布尔值', en: 'Boolean', ja: '真偽値' },
null: { zh: '空值', en: 'Null', ja: '空値' },
array: { zh: '列表', en: 'List', ja: 'リスト' },
map: { zh: '对象', en: 'Object', ja: 'オブジェクト' },
};
return <div className="structured-any">
<select className="select-input" aria-label={`${label} ${tx({ zh: '类型', en: 'type', ja: 'の型' })}`} value={current} disabled={disabled}
onChange={event => onChange(({ string: '', number: 0, boolean: false, null: null, array: [], map: {} } as Record<string, unknown>)[event.currentTarget.value])}>
{Object.entries(types).map(([type, text]) => <option key={type} value={type}>{tx(text)}</option>)}
</select>
{current !== 'null' && <StructuredConfigEditor shape={{ type: current as ConfigShape['type'], item: { type: 'any' }, label: shape.label, integer: false }} value={value} onChange={onChange} disabled={disabled} id={id} />}
</div>;
}
if (shape.type === 'array') {
const rows = Array.isArray(value) ? value : [];
const item = shape.item ?? { type: 'any' };
const update = (index: number, next: unknown) => onChange(rows.map((row, i) => i === index ? next : row));
const move = (index: number, delta: number) => {
const next = [...rows];
[next[index], next[index + delta]] = [next[index + delta], next[index]];
onChange(next);
};
return <div className="structured-list">
{rows.map((row, index) => <div className="structured-list-item" key={index}>
<div className="structured-list-toolbar"><span>{index + 1}</span><div>
<button type="button" className="icon-button quiet" disabled={disabled || index === 0} onClick={() => move(index, -1)} aria-label={tx({ zh: '上移', en: 'Move up', ja: '上へ' })}><ArrowUp size={14} /></button>
<button type="button" className="icon-button quiet" disabled={disabled || index === rows.length - 1} onClick={() => move(index, 1)} aria-label={tx({ zh: '下移', en: 'Move down', ja: '下へ' })}><ArrowDown size={14} /></button>
<button type="button" className="icon-button quiet" disabled={disabled} onClick={() => onChange(rows.filter((_, i) => i !== index))} aria-label={`${removeText} ${index + 1}`}><Trash2 size={14} /></button>
</div></div>
<StructuredConfigEditor shape={{ ...item, label: item.label ?? `${label} ${index + 1}` }} value={row} onChange={next => update(index, next)} disabled={disabled} id={`${id}-${index}`} />
</div>)}
<button type="button" className="secondary-button structured-add" disabled={disabled} onClick={() => onChange([...rows, defaultStructuredValue(item)])}><Plus size={14} />{addText}</button>
</div>;
}
if (shape.type === 'object') {
const record = configRecord(value) ? value : {};
const update = (key: string, next: unknown) => {
const result = { ...record };
if (next === undefined) delete result[key]; else result[key] = next;
onChange(result);
};
return <div className="structured-object">
{Object.entries(shape.fields ?? {}).map(([key, field]) => <div key={key} className={`structured-field${['array', 'map', 'object', 'any'].includes(field.type) ? ' structured-field-wide' : ''}`}>
<div className="structured-label"><label htmlFor={`${id}-${key}`}>{tx(field.label) || key}</label>
{field.optional && record[key] !== undefined && <button type="button" className="icon-button quiet" disabled={disabled} onClick={() => update(key, undefined)} title={inheritText} aria-label={`${inheritText}: ${tx(field.label) || key}`}><Undo2 size={13} /></button>}
</div>
{field.optional && record[key] == null ? <button type="button" className="secondary-button structured-unset" disabled={disabled} aria-label={`${tx(field.label) || key}: ${inheritText}`} onClick={() => update(key, defaultStructuredValue(field))}>{inheritText} · {tx({ zh: '设置', en: 'Set', ja: '設定' })}</button>
: <StructuredConfigEditor shape={field} value={record[key]} onChange={next => update(key, next)} disabled={disabled} id={`${id}-${key}`} />}
{field.hint && <small>{tx(field.hint)}</small>}
</div>)}
</div>;
}
if (shape.type === 'map') {
const record = configRecord(value) ? value : {};
const item = shape.item ?? { type: 'any' };
return <div className="structured-map">
{Object.entries(record).map(([key, entry]) => <div className="structured-map-item" key={key}>
<div className="structured-map-key"><strong>{key}</strong><button type="button" className="icon-button quiet" disabled={disabled} aria-label={`${removeText} ${key}`} onClick={() => {
const next = { ...record }; delete next[key]; onChange(next);
}}><Trash2 size={14} /></button></div>
<StructuredConfigEditor shape={{ ...item, label: item.label ?? key }} value={entry} onChange={next => onChange({ ...record, [key]: next })} disabled={disabled} id={`${id}-${key}`} />
</div>)}
<div className="structured-map-add"><input className="text-input" aria-label={tx({ zh: '新字段名称', en: 'New field name', ja: '新しいフィールド名' })} value={newKey} disabled={disabled} placeholder={tx({ zh: '字段 / 通道名称', en: 'Field / channel name', ja: 'フィールド / チャンネル名' })}
onChange={event => { setNewKey(event.currentTarget.value); setKeyError(''); }} />
<button type="button" className="secondary-button" disabled={disabled || !newKey.trim()} onClick={() => {
const key = newKey.trim();
if (Object.prototype.hasOwnProperty.call(record, key) || ['__proto__', 'prototype', 'constructor'].includes(key)) { setKeyError(tx({ zh: '名称已存在或不可使用', en: 'Name already exists or is not allowed', ja: 'この名前は使用できません' })); return; }
onChange({ ...record, [key]: defaultStructuredValue(item) }); setNewKey('');
}}><Plus size={14} />{addText}</button></div>
{keyError && <p role="alert" className="structured-error">{keyError}</p>}
</div>;
}
if (shape.type === 'boolean' || shape.type === 'select') {
const options = shape.type === 'boolean' ? [true, false] : shape.options ?? [];
return <select id={id} className="select-input" aria-label={label} disabled={disabled} value={String(value)} onChange={event => onChange(options.find(option => String(option) === event.currentTarget.value))}>
{!options.some(option => option === value) && <option value={String(value)}>{String(value ?? '')}</option>}
{options.map(option => <option value={String(option)} key={String(option)}>{option === true ? tx({ zh: '启用', en: 'Enabled', ja: '有効' }) : option === false ? tx({ zh: '禁用', en: 'Disabled', ja: '無効' }) : String(option)}</option>)}
</select>;
}
return <input id={id} aria-label={label} className="text-input" type={shape.secret ? 'password' : shape.type === 'number' ? 'number' : 'text'} autoComplete="off" spellCheck={false} disabled={disabled}
min={shape.min} max={shape.max} step={shape.type === 'number' ? shape.integer === false ? 'any' : 1 : undefined} value={typeof value === 'string' || typeof value === 'number' ? value : ''}
onChange={event => { const text = event.currentTarget.value; onChange(shape.type === 'number' && text !== '' && Number.isFinite(Number(text)) ? Number(text) : text); }} />;
}
+186
View File
@@ -0,0 +1,186 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { open } from '@tauri-apps/plugin-dialog';
import { RotateCcw, Save, Settings2 } from 'lucide-react';
import { useI18n } from '../i18n';
import { templateMessages, templateText } from '../i18n/templateConfig';
import { useCoreRuntime, type CoreStatus } from '../coreRuntime';
import {
applyTemplateChanges, draftTemplateField, readTemplatePath, templateFieldKey, templateFieldValidation, templateFieldSaveValue,
type TemplateConfigChange, type TemplateConfigDraft, type TemplateConfigField, type TemplateConfigGroup, type TemplateConfigSaveResult,
} from '../services/templateConfig';
import '../styles/template-config.css';
export function TemplateConfigSection({ groups, visibleGroups }: {
groups: readonly TemplateConfigGroup[];
visibleGroups?: readonly string[];
}) {
const { locale } = useI18n();
const { status, publishStatus, refreshStatus } = useCoreRuntime();
const [config, setConfig] = useState<Record<string, unknown> | null>(null);
const [drafts, setDrafts] = useState<Record<string, TemplateConfigDraft>>({});
const [loadError, setLoadError] = useState('');
const [groupErrors, setGroupErrors] = useState<Record<string, string>>({});
const [saved, setSaved] = useState<Record<string, boolean>>({});
const [restartNeeded, setRestartNeeded] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const mounted = useRef(true);
const revision = useRef(0);
const tr = (message: keyof typeof templateMessages) => templateText(templateMessages[message], locale);
const reload = useCallback(async () => {
const request = ++revision.current;
try {
const value = await invoke<Record<string, unknown>>('get_extended_core_config');
if (mounted.current && request === revision.current) { setConfig(value); setLoadError(''); }
} catch (error) {
if (mounted.current && request === revision.current) setLoadError(String(error));
}
}, []);
useEffect(() => {
mounted.current = true;
let disposed = false;
let stop: (() => void) | undefined;
void reload();
void listen('config-files-changed', () => { if (!disposed) void reload(); }).then((unlisten) => {
if (!disposed) stop = unlisten;
else unlisten();
}).catch((error) => { if (!disposed) setLoadError(String(error)); });
return () => { disposed = true; mounted.current = false; ++revision.current; stop?.(); };
}, [reload]);
const changeField = (group: TemplateConfigGroup, field: TemplateConfigField, value: unknown, remove = false) => {
if (!config) return;
const key = templateFieldKey(field.path);
setDrafts((previous) => {
const next = { ...previous };
const draft = draftTemplateField(field, config, previous[key], value, remove);
if (draft) next[key] = draft;
else delete next[key];
return next;
});
setGroupErrors((previous) => ({ ...previous, [group.id]: '' }));
setSaved((previous) => ({ ...previous, [group.id]: false }));
};
const discardGroup = (group: TemplateConfigGroup) => {
setDrafts((previous) => {
const next = { ...previous };
for (const field of group.fields) delete next[templateFieldKey(field.path)];
return next;
});
setGroupErrors((previous) => ({ ...previous, [group.id]: '' }));
setSaved((previous) => ({ ...previous, [group.id]: false }));
};
const saveGroup = async (group: TemplateConfigGroup) => {
if (!config || busy) return;
const changes: TemplateConfigChange[] = [];
for (const field of group.fields) {
const draft = drafts[templateFieldKey(field.path)];
if (!draft) continue;
const error = !draft.remove && templateFieldValidation(field, draft.value);
if (error) {
setGroupErrors((previous) => ({ ...previous, [group.id]: `${templateText(field.label, locale)}: ${templateText(error, locale)}` }));
return;
}
changes.push({ ...draft, path: [...field.path], value: draft.remove ? null : templateFieldSaveValue(field, draft.value) });
}
if (!changes.length) return;
const groupError = group.validate?.(applyTemplateChanges(config, changes));
if (groupError) { setGroupErrors((previous) => ({ ...previous, [group.id]: templateText(groupError, locale) })); return; }
setBusy(group.id);
setGroupErrors((previous) => ({ ...previous, [group.id]: '' }));
try {
const result = await invoke<TemplateConfigSaveResult>('save_extended_core_config', { changes });
++revision.current;
setConfig(result.config);
setLoadError('');
setDrafts((previous) => {
const next = { ...previous };
for (const change of changes) delete next[templateFieldKey(change.path)];
return next;
});
setSaved((previous) => ({ ...previous, [group.id]: true }));
if (result.restartRequired) setRestartNeeded(true);
} catch (error) {
setGroupErrors((previous) => ({ ...previous, [group.id]: String(error) }));
// Refresh untouched values while retaining both the draft and its original conflict check.
await reload();
} finally { if (mounted.current) setBusy(null); }
};
const restart = async () => {
setBusy('restart');
try { publishStatus(await invoke<CoreStatus>('restart_core_process')); setRestartNeeded(false); setLoadError(''); }
catch (error) { setLoadError(String(error)); await refreshStatus(); }
finally { setBusy(null); }
};
const chooseDirectory = async (group: TemplateConfigGroup, field: TemplateConfigField) => {
setBusy('directory');
try {
const selected = await open({ directory: true, multiple: false, title: templateText(field.label, locale) });
if (mounted.current && typeof selected === 'string') changeField(group, field, selected);
} catch (error) {
if (mounted.current) setGroupErrors((previous) => ({ ...previous, [group.id]: String(error) }));
} finally { if (mounted.current) setBusy(null); }
};
const control = (group: TemplateConfigGroup, field: TemplateConfigField, value: unknown, id: string, invalid: boolean) => {
const disabled = busy !== null || config === null;
const onChange = (next: unknown) => changeField(group, field, next);
const common = { id, disabled, 'aria-invalid': invalid || undefined, 'aria-describedby': `${id}-description` };
if (field.type === 'custom' && field.render) return field.render({ value, onChange, disabled, id });
if (field.type === 'boolean') return <label className="switch-control"><input {...common} type="checkbox" checked={value === true} onChange={(event) => onChange(event.currentTarget.checked)} /><span className="switch-track" /></label>;
if (field.type === 'select') {
const selected = field.options?.findIndex((option) => Object.is(option.value, value)) ?? -1;
return <select {...common} className="config-network-input" value={String(selected)} onChange={(event) => onChange(field.options?.[Number(event.currentTarget.value)]?.value)}>
{selected < 0 ? <option value="-1">{String(value ?? '')}</option> : null}
{field.options?.map((option, index) => <option key={index} value={String(index)}>{templateText(option.label, locale)}</option>)}
</select>;
}
if (field.type === 'string-list' || field.type === 'json') return <textarea {...common} className="config-network-input template-config-textarea" rows={field.rows ?? (field.type === 'json' ? 6 : 3)}
value={field.type === 'string-list' ? Array.isArray(value) ? value.join('\n') : '' : typeof value === 'string' ? value : JSON.stringify(value ?? {}, null, 2)}
placeholder={field.placeholder}
onChange={(event) => onChange(field.type === 'string-list' ? event.currentTarget.value.split('\n') : event.currentTarget.value)}
onBlur={() => { if (field.type === 'string-list' && Array.isArray(value)) { const clean = value.map(String).map((item) => item.trim()).filter(Boolean); if (JSON.stringify(clean) !== JSON.stringify(value)) onChange(clean); } }} />;
const input = <input {...common} className="config-network-input" type={field.type === 'number' ? 'number' : field.sensitive ? 'password' : 'text'} autoComplete={field.sensitive ? 'new-password' : undefined}
min={field.min} max={field.max} step={field.step ?? 1} placeholder={field.placeholder} value={typeof value === 'string' || typeof value === 'number' ? value : ''}
onChange={(event) => onChange(field.type === 'number' && event.currentTarget.value !== '' ? Number(event.currentTarget.value) : event.currentTarget.value)} />;
return field.directory ? <div className="template-config-directory">{input}<button type="button" className="secondary-button compact-button" disabled={disabled} onClick={() => void chooseDirectory(group, field)} aria-label={`${tr('chooseDirectory')}: ${templateText(field.label, locale)}`}>{tr('chooseDirectory')}</button></div> : input;
};
const visible = groups.filter((group) => !visibleGroups || visibleGroups.includes(group.id));
return <div className="template-config-section" hidden={visible.length === 0}>
{loadError ? <div className="template-config-notice template-config-error" role="alert"><span>{loadError}</span><button type="button" className="secondary-button compact-button" disabled={busy !== null} onClick={() => void reload()}>{tr('reload')}</button></div> : null}
{!config && !loadError ? <p role="status">{tr('loading')}</p> : null}
{restartNeeded ? <div className="template-config-notice" role="status"><span>{tr('restart')}</span>{status?.running ? <button type="button" className="secondary-button compact-button" disabled={busy !== null} onClick={() => void restart()}>{tr('restartNow')}</button> : null}</div> : null}
{groups.map((group) => {
const dirty = group.fields.some((field) => drafts[templateFieldKey(field.path)]);
return <section key={group.id} className="config-card template-config-card" hidden={!visible.includes(group)} aria-labelledby={`template-group-${group.id}`}>
<div className="template-config-heading"><div><h3 id={`template-group-${group.id}`}><Settings2 size={18} aria-hidden="true" />{templateText(group.title, locale)}</h3>{group.description ? <p>{templateText(group.description, locale)}</p> : null}</div></div>
<div className="template-config-fields">{group.fields.map((field, index) => {
const key = templateFieldKey(field.path);
const original = readTemplatePath(config, field.path);
const draft = drafts[key];
const exists = draft ? !draft.remove : original.exists;
const value = draft && !draft.remove ? draft.value : draft?.remove || !original.exists ? field.defaultValue : original.value;
const validation = draft && !draft.remove ? templateFieldValidation(field, value) : null;
const id = `template-${group.id}-${index}`;
return <div key={key} className={`template-config-field${field.type === 'custom' || field.type === 'json' || field.type === 'string-list' ? ' template-config-field-wide' : ''}${field.type === 'boolean' ? ' template-config-toggle' : ''}`}>
<div className="template-config-label">{field.type === 'custom' ? <span id={`${id}-label`}>{templateText(field.label, locale)}</span> : <label htmlFor={id}>{templateText(field.label, locale)}</label>}<span className={`template-config-origin${draft ? ' template-config-origin-dirty' : ''}`}>{draft ? tr('dirty') : exists ? tr('explicit') : tr('inherited')}</span></div>
{field.type === 'custom' ? <details className="template-config-custom"><summary aria-labelledby={`${id}-label ${id}-edit`} aria-describedby={`${id}-description`}><span id={`${id}-edit`}>{tr('editDetails')}</span></summary><div className="template-config-control" role="group" aria-labelledby={`${id}-label`}>{control(group, field, value, id, Boolean(validation))}</div></details> : <div className="template-config-control">{control(group, field, value, id, Boolean(validation))}</div>}
<small id={`${id}-description`}>{templateText(field.description, locale)}{field.type === 'string-list' ? ` ${tr('list')}` : ''}</small>
{validation ? <small className="template-config-error" role="alert">{templateText(validation, locale)}</small> : null}
{exists ? <button type="button" className="template-config-reset" disabled={busy !== null || !config} aria-label={`${tr('default')}: ${templateText(field.label, locale)}`} onClick={() => changeField(group, field, null, true)}><RotateCcw size={12} aria-hidden="true" />{tr('default')}</button> : null}
</div>;
})}</div>
{groupErrors[group.id] ? <p className="template-config-error" role="alert">{groupErrors[group.id]}</p> : null}
<div className="template-config-actions"><span role="status">{dirty ? tr('dirty') : saved[group.id] ? tr('saved') : ''}</span><div>{dirty ? <button type="button" className="secondary-button compact-button" disabled={busy !== null} onClick={() => discardGroup(group)}>{tr('discard')}</button> : null}<button type="button" className="primary-button compact-button" disabled={!dirty || busy !== null || !config} onClick={() => void saveGroup(group)}><Save size={15} aria-hidden="true" />{busy === group.id ? tr('saving') : tr('save')}</button></div></div>
</section>;
})}
</div>;
}
+57
View File
@@ -0,0 +1,57 @@
import { getCurrentLocale, type AppLocale } from './index';
import { createTraditionalMessages } from './traditional';
const messages = {
inherit: ['继承 / 默认', 'Inherit / default', '継承 / 既定'],
configure: ['自定义', 'Configure', '設定する'],
enabled: ['启用', 'Enabled', '有効'],
disabled: ['关闭', 'Disabled', '無効'],
options: ['模型能力与兼容选项', 'Model capabilities and compatibility', 'モデル能力と互換性'],
retry: ['额外重试轮次', 'Additional retry rounds', '追加リトライ回数'],
retryHint: ['0 禁用额外轮次;未设置或负数继承全局设置。', '0 disables additional rounds; unset or negative values inherit the global setting.', '0 は追加リトライなし。未設定または負数はグローバル設定を継承します。'],
errors: ['请求错误规则', 'Request error rules', 'リクエストエラー規則'],
errorsHint: ['按 HTTP 状态码与正文匹配;空列表明确清除继承的规则。', 'Match HTTP status and body patterns. An empty list clears inherited rules.', 'HTTP ステータスと本文を照合します。空のリストは継承した規則を解除します。'],
addRule: ['添加规则', 'Add rule', '規則を追加'],
remove: ['删除', 'Remove', '削除'],
status: ['HTTP 状态码', 'HTTP status', 'HTTP ステータス'],
match: ['正文包含(每行一项)', 'Body contains (one per line)', '本文に含む文字列(行ごと)'],
regex: ['正则表达式(每行一项)', 'Regular expressions (one per line)', '正規表現(行ごと)'],
action: ['处理方式', 'Action', '処理'],
stop: ['返回错误,不冷却', 'Return error, no cooldown', 'エラーを返す・冷却なし'],
'stop-and-cooldown': ['返回错误并冷却', 'Return error and cool down', 'エラーを返す・冷却あり'],
continue: ['尝试下一凭据,不冷却', 'Try next credential, no cooldown', '次の認証情報・冷却なし'],
'continue-and-cooldown': ['尝试下一凭据并冷却', 'Try next credential and cool down', '次の認証情報・冷却あり'],
display: ['显示名称', 'Display name', '表示名'],
context: ['上下文窗口', 'Context window', 'コンテキスト長'],
force: ['响应模型名称映射回别名', 'Map response model back to alias', '応答モデル名を別名に戻す'],
compat: ['启用上游兼容模式', 'Enable upstream compatibility', '上流互換モードを有効化'],
configuration: ['支持 configuration_update', 'Support configuration_update', 'configuration_update 対応'],
image: ['启用图像生成与编辑接口', 'Enable image generation and edit endpoints', '画像生成・編集エンドポイントを有効化'],
imageHint: ['仅用于 /v1/images/*;聊天图像输入由输入模态控制。', 'Only /v1/images/*; chat image input is controlled by input modalities.', '/v1/images/* のみ。チャット画像入力は入力モダリティで設定します。'],
input: ['输入模态', 'Input modalities', '入力モダリティ'],
output: ['输出模态', 'Output modalities', '出力モダリティ'],
completion: ['使用 max_completion_tokens', 'Use max_completion_tokens', 'max_completion_tokens を使用'],
thinking: ['思考能力', 'Thinking capabilities', '思考能力'],
levels: ['支持的思考等级', 'Supported reasoning levels', '対応する思考レベル'],
min: ['最小思考预算', 'Minimum thinking budget', '最小思考予算'],
max: ['最大思考预算', 'Maximum thinking budget', '最大思考予算'],
zero: ['允许零预算', 'Allow zero budget', 'ゼロ予算を許可'],
dynamic: ['允许动态预算', 'Allow dynamic budget', '動的予算を許可'],
linesHint: ['每行一项;启用后留空表示空列表。', 'One per line; an enabled empty field is an empty list.', '1 行に 1 項目。有効な空欄は空のリストになります。'],
headersHint: ['值以 $ 开头时复制下游请求中的同名指定请求头,例如 $X-Session。', 'Values starting with $ copy the named downstream request header, e.g. $X-Session.', '$ で始まる値は指定した下流リクエストヘッダーをコピーします(例: $X-Session)。'],
promptCache: ['自动生成 prompt_cache_key', 'Derive prompt_cache_key', 'prompt_cache_key を生成'],
noCloak: ['禁用 Codex 请求伪装', 'Disable Codex request cloaking', 'Codex リクエスト偽装を無効化'],
alpha: ['启用 Alpha Search', 'Enable Alpha Search', 'Alpha Search を有効化'],
rebuild: ['将中途 system 消息移至系统提示', 'Move mid-conversation system messages to the system prompt', '会話中の system メッセージをシステムプロンプトへ移動'],
fingerprint: ['请求指纹', 'Request fingerprint', 'リクエスト指紋'],
caller: ['保持调用方请求指纹', 'Keep caller fingerprint', '呼び出し元の指紋を保持'],
cch: ['旧版 CCH 签名兼容字段', 'Legacy CCH signing compatibility', '旧 CCH 署名互換フィールド'],
deprecated: ['已废弃;新内核自动生成 CCH 签名。', 'Deprecated; current kernels generate CCH signatures automatically.', '非推奨。現在のカーネルは CCH 署名を自動生成します。'],
invalid: ['配置值无效,请检查标注字段:', 'Invalid configuration; check field: ', '設定値が無効です。確認する項目: '],
modelHint: ['字段关闭时保留默认行为;已启用字段中的 false、0 和空列表会明确覆盖默认值。', 'Unset fields use defaults; explicit false, 0 and empty lists override defaults.', '未設定は既定動作。false、0、空のリストは既定値を明示的に上書きします。'],
} as const;
export type ProviderTemplateMessage = keyof typeof messages;
export function providerText(key: ProviderTemplateMessage, locale: AppLocale = getCurrentLocale()): string {
const value = messages[key][locale === 'en' ? 1 : locale === 'ja' ? 2 : 0];
return locale === 'zh-TW' ? createTraditionalMessages({ text: value }).text : value;
}
+42
View File
@@ -0,0 +1,42 @@
import type { AppLocale } from './index';
import { createTraditionalMessages } from './traditional';
import type { TemplateText } from '../services/templateConfig';
export function templateText(value: TemplateText | undefined, locale: AppLocale): string {
if (value === undefined) return '';
if (typeof value === 'string') return value;
if (locale === 'zh-CN') return value.zh;
if (locale === 'zh-TW') return createTraditionalMessages({ text: value.zh }).text;
return locale === 'ja' ? value.ja ?? value.en : value.en;
}
export const templateMessages = {
editDetails: { zh: '展开编辑', en: 'Edit details', ja: '詳細を編集' },
disableManagement: { zh: '禁用管理接口', en: 'Disable management API', ja: '管理 API を無効化' },
disableManagementMessage: { zh: '清空管理密钥后,内核管理接口将返回 404,管理面板及依赖管理接口的桌面功能会不可用。可在此处设置新的管理密钥以恢复。', en: 'Clearing the key makes management endpoints return 404. The control panel and desktop features that use the management API will be unavailable. Set a new management key here to restore access.', ja: 'キーを削除すると管理 API は 404 を返し、管理パネルと管理 API を使うデスクトップ機能が利用できなくなります。ここで新しいキーを設定すると復元できます。' },
managementDisabled: { zh: '管理接口已禁用,可设置新密钥以恢复', en: 'Management API disabled. Set a new key to restore access.', ja: '管理 API は無効です。新しいキーを設定すると復元できます。' },
save: { zh: '保存设置', en: 'Save settings', ja: '設定を保存' },
saving: { zh: '正在保存…', en: 'Saving…', ja: '保存中…' },
saved: { zh: '设置已保存', en: 'Settings saved', ja: '設定を保存しました' },
restart: { zh: '设置已保存,下次启动内核时生效', en: 'Saved. These changes take effect the next time the core starts.', ja: '保存しました。次回コア起動時に適用されます。' },
restartNow: { zh: '重启内核以应用', en: 'Restart core to apply', ja: 'コアを再起動して適用' },
default: { zh: '使用内核默认值', en: 'Use core default', ja: 'コアの既定値を使用' },
inherited: { zh: '内核默认', en: 'Core default', ja: 'コアの既定値' },
explicit: { zh: '已单独配置', en: 'Configured', ja: '設定済み' },
dirty: { zh: '有未保存修改', en: 'Unsaved changes', ja: '未保存の変更' },
discard: { zh: '放弃本组修改', en: 'Discard changes', ja: '変更を破棄' },
reload: { zh: '重新读取', en: 'Reload', ja: '再読み込み' },
loading: { zh: '正在读取内核配置…', en: 'Loading core configuration…', ja: 'コア設定を読み込み中…' },
list: { zh: '每行一项;留空保存空列表', en: 'One item per line; leave blank to save an empty list.', ja: '1 行に 1 項目。空欄で空のリストを保存します。' },
requests: { zh: '请求与兼容', en: 'Requests & compatibility', ja: 'リクエストと互換性' },
oauth: { zh: 'OAuth 设置', en: 'OAuth settings', ja: 'OAuth 設定' },
extensions: { zh: '扩展设置', en: 'Extensions', ja: '拡張設定' },
weighted: { zh: '加权轮询', en: 'Weighted round robin', ja: '加重ラウンドロビン' },
hostHint: { zh: '留空监听所有 IPv4/IPv6 接口,也可填写 localhost 或具体 IP;修改后重启内核', en: 'Leave blank to bind all IPv4/IPv6 interfaces, or enter localhost or an IP address. Changes restart the core.', ja: '空欄ではすべての IPv4/IPv6 インターフェースで待ち受けます。localhost または IP アドレスも指定できます。変更後はコアを再起動します。' },
retryHint: { zh: '首轮遍历后额外重试的轮数,适用于 403、408、429、500、502、503、504;0 仍执行首轮', en: 'Additional credential rounds after the initial round, for 403, 408, 429, 500, 502, 503 and 504. Zero keeps only the initial round.', ja: '初回の認証情報試行後に追加する再試行ラウンド数です。403、408、429、500、502、503、504 に適用し、0 でも初回のラウンドは実行します。' },
credentialsHint: { zh: '每轮最多尝试的不同凭据数;0 表示本轮尝试全部可用凭据', en: 'Maximum different credentials per retry round; zero tries all eligible credentials in that round.', ja: '各再試行ラウンドで試す異なる認証情報の上限です。0 ではそのラウンドで利用可能なすべての認証情報を試します。' },
waitHint: { zh: '重试轮次之间等待冷却的最长秒数;0 或负数表示不等待,不限制无需等待的重试轮次', en: 'Maximum cooldown wait between retry rounds. Zero or negative skips waiting without disabling rounds that need no wait.', ja: '再試行ラウンド間でクールダウンを待つ最大秒数です。0 または負の値では待機しません。待機不要の再試行ラウンドは制限しません。' },
retryRange: { zh: '重试轮数与凭据上限须为非负整数;等待时间可为负整数', en: 'Retry rounds and credential limits must be non-negative integers; cooldown wait may be a negative integer.', ja: '再試行ラウンド数と認証情報の上限には 0 以上の整数を指定してください。待機時間には負の整数も指定できます。' },
diagnostics: { zh: '应用日志、请求报文日志和用量统计独立配置;请求日志可能包含请求与响应内容。', en: 'Application logs, request/response logs and usage aggregation are configured separately. Request logs may include request and response content.', ja: 'アプリログ、リクエストと応答のログ、使用量集計は個別に設定します。リクエストログには送受信した内容が含まれる場合があります。' },
chooseDirectory: { zh: '选择目录', en: 'Choose directory', ja: 'フォルダーを選択' },
} satisfies Record<string, TemplateText>;
+138 -53
View File
@@ -1,3 +1,5 @@
import { applyTemplateChanges, readTemplatePath, sameTemplateValue, type TemplateConfigChange } from '../services/templateConfig';
export type BrowserMockScenario = 'running' | 'stopped' | 'empty' | 'error';
export type BrowserMockMode = BrowserMockScenario | 'off';
@@ -18,14 +20,23 @@ const SCENARIOS = new Set<BrowserMockScenario>(['running', 'stopped', 'empty', '
const BUNDLED_CORE = { version: '7.3.15', assetName: 'CLIProxyAPI_7.3.15_windows_amd64.zip' };
const PROVIDER_SECTIONS = [
'gemini-api-key',
'interactions-api-key',
'vertex-api-key',
'codex-api-key',
'claude-api-key',
'xai-api-key',
'meta-api-key',
'openai-compatibility',
] as const;
type ProviderSection = (typeof PROVIDER_SECTIONS)[number];
const V8_PROVIDER_BY_SECTION = {
'gemini-api-key': 'gemini',
'interactions-api-key': 'interactions',
'vertex-api-key': 'vertex',
'codex-api-key': 'codex',
'claude-api-key': 'claude',
'xai-api-key': 'xai',
'meta-api-key': 'meta',
'openai-compatibility': 'openai-compatibility',
} as const;
const SHARED_PROVIDER_FIELDS = new Set([
@@ -51,6 +62,38 @@ const readNumber = (value: unknown, fallback = 0) => {
return Number.isFinite(parsed) ? parsed : fallback;
};
function groupProviderRecords(section: ProviderSection, records: JsonObject[]): JsonObject[] {
return records.map((record, index) => {
if (section === 'openai-compatibility') {
const group: JsonObject = { ...clone(record), keys: asArray(record['api-key-entries']).map((item) => clone(asObject(item))) };
delete group['api-key-entries'];
return group;
}
const group: JsonObject = { name: readString(record.name).trim() || `${V8_PROVIDER_BY_SECTION[section]}-${index + 1}` };
const key: JsonObject = {};
Object.entries(record).forEach(([field, value]) => {
if (field === 'base-url' || SHARED_PROVIDER_FIELDS.has(field)) group[field] = clone(value);
else key[field] = clone(value);
});
group.keys = [key];
return group;
});
}
function flattenProviderGroups(section: ProviderSection, groups: JsonObject[]): JsonObject[] {
return groups.flatMap((group) => {
const keys = asArray(group.keys).map(asObject);
if (section === 'openai-compatibility') {
const record: JsonObject = clone(group);
delete record.keys;
if (keys.length > 0) record['api-key-entries'] = clone(keys);
return [record];
}
const shared = Object.fromEntries(Object.entries(group).filter(([field]) => field === 'base-url' || SHARED_PROVIDER_FIELDS.has(field)));
return keys.map((key) => clone({ ...shared, ...Object.fromEntries(Object.entries(key).filter(([, value]) => value !== null)) }));
});
}
const isoHoursAgo = (hours: number) => new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
const localHourKey = (date: Date) => [
date.getFullYear(),
@@ -285,7 +328,11 @@ function createState(scenario: BrowserMockScenario) {
failed: 0,
},
];
const providerConfig: Record<(typeof PROVIDER_SECTIONS)[number], JsonObject[]> = {
const legacyProviderConfig: Record<ProviderSection, JsonObject[]> = {
'interactions-api-key': [],
'vertex-api-key': [],
'xai-api-key': [],
'meta-api-key': [],
'codex-api-key': [
{
name: 'Codex Direct',
@@ -380,6 +427,9 @@ function createState(scenario: BrowserMockScenario) {
errorLogsMaxFiles: 10,
usageStatisticsEnabled: true,
redisUsageQueueRetentionSeconds: 60,
requestLog: false,
pluginsEnabled: false,
managementSecretConfigured: true,
host: '127.0.0.1',
port: 8317,
allowLan: false,
@@ -394,6 +444,18 @@ function createState(scenario: BrowserMockScenario) {
maxRetryInterval: 30,
streamingBootstrapRetries: 0,
},
extendedConfig: {
'config-version': 8,
server: { host: '127.0.0.1', port: 8317, 'commercial-mode': false, tls: { enable: false, cert: '', key: '' } },
management: { 'allow-remote': false, 'disable-control-panel': false },
routing: {
strategy: 'round-robin', 'session-affinity': true, 'session-affinity-ttl': '30m',
retry: { 'request-retry': 3, 'max-retry-credentials': 0, 'max-retry-interval': 30 },
cooldown: { 'disable-cooling': false },
},
requests: { 'proxy-url': '', streaming: { 'bootstrap-retries': 0 } },
observability: { logs: { debug: false, 'logging-to-file': true, 'logs-max-total-size-mb': 256, 'error-logs-max-files': 10 }, usage: { 'usage-statistics-enabled': true, 'redis-usage-queue-retention-seconds': 60 } },
} as JsonObject,
softwareSettings: {
closeBehavior: 'ask',
autostartEnabled: false,
@@ -447,7 +509,10 @@ function createState(scenario: BrowserMockScenario) {
result: null,
},
versionSource: { source: 'github', gitcodeAvailable: true, customMirrors: ['https://gh-proxy.example.com/'] },
providerConfig,
// Native groups are authoritative; legacy routes only adapt their own view.
providerConfig: Object.fromEntries(PROVIDER_SECTIONS.map((section) => [
section, groupProviderRecords(section, legacyProviderConfig[section]),
])) as Record<ProviderSection, JsonObject[]>,
authFiles,
oauthExcludedModels: { codex: [], claude: ['claude-legacy-*'], gemini: [] } as Record<string, string[]>,
oauthSessions: new Map<string, string>(),
@@ -654,64 +719,37 @@ function managementResponse(state: BrowserMockState, payload: JsonObject) {
const v8Provider = Object.entries(V8_PROVIDER_BY_SECTION).find(
([, provider]) => path === `/config/api-keys/${provider}`,
);
const groupRecords = (section: (typeof PROVIDER_SECTIONS)[number], records: JsonObject[]) => records.map((record, index) => {
if (section === 'openai-compatibility') {
const group: JsonObject = { ...record, keys: asArray(record['api-key-entries']).map((item) => clone(asObject(item))) };
delete group['api-key-entries'];
return group;
}
const group: JsonObject = { name: `${V8_PROVIDER_BY_SECTION[section]}-${index + 1}` };
const key: JsonObject = {};
Object.entries(record).forEach(([field, value]) => {
if (field === 'base-url' || SHARED_PROVIDER_FIELDS.has(field)) group[field] = value;
else key[field] = value;
});
group.keys = [key];
return group;
});
const flattenGroups = (section: (typeof PROVIDER_SECTIONS)[number], groups: unknown) => asArray(groups).flatMap((item) => {
const group = asObject(item);
const keys = asArray(group.keys).map(asObject);
if (section === 'openai-compatibility') {
const record: JsonObject = { ...group };
delete record.keys;
if (keys.length > 0) record['api-key-entries'] = keys;
return [record];
}
const shared = Object.fromEntries(Object.entries(group).filter(([field]) => field === 'base-url' || SHARED_PROVIDER_FIELDS.has(field)));
return keys.map((key) => ({ ...shared, ...Object.fromEntries(Object.entries(key).filter(([, value]) => value !== null)) }));
});
if (method === 'GET' && path === '/config') {
return {
...clone(state.extendedConfig),
'config-version': 8,
'api-keys': Object.fromEntries(PROVIDER_SECTIONS.map((section) => [
V8_PROVIDER_BY_SECTION[section],
groupRecords(section, state.providerConfig[section]),
clone(state.providerConfig[section]),
])),
};
}
if (v8Provider) {
const section = v8Provider[0] as (typeof PROVIDER_SECTIONS)[number];
if (method === 'GET') return groupRecords(section, state.providerConfig[section]);
const section = v8Provider[0] as ProviderSection;
if (method === 'GET') return clone(state.providerConfig[section]);
if (method === 'PUT') {
state.providerConfig[section] = flattenGroups(section, body).map((item) => clone(asObject(item)));
state.providerConfig[section] = asArray(body).map((item) => clone(asObject(item)));
return { status: 'ok', 'config-version': 8 };
}
}
if (PROVIDER_SECTIONS.some((section) => path === `/${section}`)) {
const section = path.slice(1) as (typeof PROVIDER_SECTIONS)[number];
if (method === 'GET') return { [section]: clone(state.providerConfig[section]) };
const section = path.slice(1) as ProviderSection;
if (method === 'GET') return { [section]: flattenProviderGroups(section, state.providerConfig[section]) };
if (method === 'PUT') {
state.providerConfig[section] = asArray(body).map((item) => clone(asObject(item)));
return { [section]: clone(state.providerConfig[section]) };
state.providerConfig[section] = groupProviderRecords(section, asArray(body).map(asObject));
return { [section]: flattenProviderGroups(section, state.providerConfig[section]) };
}
if (method === 'PATCH' && section === 'openai-compatibility') {
const patch = asObject(body);
const index = readNumber(patch.index, -1);
const current = state.providerConfig[section][index];
if (current) state.providerConfig[section][index] = { ...current, ...asObject(patch.value) };
return { [section]: clone(state.providerConfig[section]) };
const current = flattenProviderGroups(section, state.providerConfig[section])[index];
if (current) state.providerConfig[section][index] = groupProviderRecords(section, [{ ...current, ...asObject(patch.value) }])[0];
return { [section]: flattenProviderGroups(section, state.providerConfig[section]) };
}
}
if (method === 'GET' && path === '/credentials') {
@@ -760,8 +798,24 @@ function managementResponse(state: BrowserMockState, payload: JsonObject) {
return {};
}
const CORE_CONFIG_PATHS: Record<string, string> = {
debug: 'observability.logs.debug', commercialMode: 'server.commercial-mode',
loggingToFile: 'observability.logs.logging-to-file', logsMaxTotalSizeMb: 'observability.logs.logs-max-total-size-mb',
errorLogsMaxFiles: 'observability.logs.error-logs-max-files', usageStatisticsEnabled: 'observability.usage.usage-statistics-enabled',
redisUsageQueueRetentionSeconds: 'observability.usage.redis-usage-queue-retention-seconds',
requestLog: 'observability.logs.request-log', pluginsEnabled: 'plugins.enabled',
host: 'server.host', port: 'server.port', routingStrategy: 'routing.strategy', proxyUrl: 'requests.proxy-url',
routingSessionAffinity: 'routing.session-affinity', routingSessionAffinityTtl: 'routing.session-affinity-ttl',
disableCooling: 'routing.cooldown.disable-cooling', requestRetry: 'routing.retry.request-retry',
maxRetryCredentials: 'routing.retry.max-retry-credentials', maxRetryInterval: 'routing.retry.max-retry-interval',
streamingBootstrapRetries: 'requests.streaming.bootstrap-retries',
};
function updateCoreConfig(state: BrowserMockState, payload: JsonObject) {
Object.assign(state.coreConfig, asObject(payload.settings));
const settings = asObject(payload.settings);
Object.assign(state.coreConfig, settings);
const changes: TemplateConfigChange[] = Object.entries(settings).flatMap(([key, value]) => CORE_CONFIG_PATHS[key] ? [{ path: CORE_CONFIG_PATHS[key].split('.'), value, remove: false, expected: null, expectedExists: false }] : []);
state.extendedConfig = applyTemplateChanges(state.extendedConfig, changes);
state.guiSettings.host = state.coreConfig.host;
state.guiSettings.port = state.coreConfig.port;
return clone(state.coreConfig);
@@ -781,6 +835,7 @@ const ERROR_SCENARIO_COMMANDS = new Set([
'get_core_status',
'get_gui_settings',
'get_core_config_settings',
'get_extended_core_config',
'check_app_update',
'check_latest_core',
'management_request',
@@ -854,6 +909,27 @@ export function createBrowserMockRuntime(
}
case 'get_gui_settings': return clone(state.guiSettings);
case 'get_core_config_settings': return clone(state.coreConfig);
case 'get_extended_core_config': return clone(state.extendedConfig);
case 'save_extended_core_config': {
const changes = asArray(payload.changes) as TemplateConfigChange[];
for (const change of changes) {
const original = readTemplatePath(state.extendedConfig, change.path);
if (original.exists !== change.expectedExists || (original.exists && !sameTemplateValue(original.value, change.expected))) throw new Error(`Configuration changed externally: ${change.path.join('.')}. Discard this group's changes and edit the latest values.`);
}
const before = state.extendedConfig;
state.extendedConfig = applyTemplateChanges(before, changes);
const syncValues: JsonObject = {};
for (const [key, path] of Object.entries(CORE_CONFIG_PATHS)) {
if (!changes.some((change) => change.path.join('.') === path)) continue;
const current = readTemplatePath(state.extendedConfig, path.split('.'));
if (current.exists) syncValues[key] = current.value;
else if (key === 'requestLog' || key === 'pluginsEnabled') syncValues[key] = false;
}
Object.assign(state.coreConfig, syncValues);
const restartRequired = changes.some((change) => !sameTemplateValue(readTemplatePath(before, change.path), readTemplatePath(state.extendedConfig, change.path)) && ['server.host', 'server.port', 'server.tls', 'server.trusted-proxies', 'management', 'oauth.auth-dir'].some((prefix) => change.path.join('.') === prefix || change.path.join('.').startsWith(`${prefix}.`)));
emit('config-files-changed', { paths: ['cpa-core/config.yaml'], errors: [] });
return { config: clone(state.extendedConfig), restartRequired };
}
case 'get_software_settings': return clone(state.softwareSettings);
case 'get_core_tls_settings': return clone(state.tlsSettings);
case 'get_core_sensitive_words_settings': return clone(state.sensitiveWords);
@@ -868,28 +944,31 @@ export function createBrowserMockRuntime(
}
case 'save_core_tls_settings': {
Object.assign(state.tlsSettings, asObject(payload.settings));
state.extendedConfig = applyTemplateChanges(state.extendedConfig, [{ path: ['server', 'tls'], value: { enable: state.tlsSettings.enabled, cert: state.tlsSettings.cert, key: state.tlsSettings.key }, remove: false, expected: null, expectedExists: false }]);
emit('config-files-changed', { paths: ['cpa-core/config.yaml'], errors: [] });
return clone(state.tlsSettings);
}
case 'save_core_logging_settings':
case 'save_network_endpoint_settings':
case 'save_retry_settings':
case 'save_session_routing_settings': return updateCoreConfig(state, payload);
case 'save_session_routing_settings': {
const config = updateCoreConfig(state, payload);
emit('config-files-changed', { paths: ['cpa-core/config.yaml'], errors: [] });
return config;
}
case 'set_core_routing_strategy': {
state.coreConfig.routingStrategy = readString(payload.strategy);
return clone(state.coreConfig);
const config = updateCoreConfig(state, { settings: { routingStrategy: readString(payload.strategy) } });
emit('config-files-changed', { paths: ['cpa-core/config.yaml'], errors: [] });
return config;
}
case 'set_core_proxy_url': {
state.coreConfig.proxyUrl = readString(payload.proxyUrl);
return clone(state.coreConfig);
return updateCoreConfig(state, { settings: { proxyUrl: readString(payload.proxyUrl) } });
}
case 'set_core_session_affinity': {
state.coreConfig.routingSessionAffinity = Boolean(payload.enabled);
return clone(state.coreConfig);
return updateCoreConfig(state, { settings: { routingSessionAffinity: Boolean(payload.enabled) } });
}
case 'set_core_session_affinity_ttl': {
state.coreConfig.routingSessionAffinityTtl = readString(payload.ttl);
return clone(state.coreConfig);
return updateCoreConfig(state, { settings: { routingSessionAffinityTtl: readString(payload.ttl) } });
}
case 'add_core_api_key': {
state.coreConfig.apiKeys.push({ apiKey: readString(payload.apiKey), remark: readString(payload.remark) });
@@ -904,8 +983,14 @@ export function createBrowserMockRuntime(
state.coreConfig.apiKeys = state.coreConfig.apiKeys.filter((item) => item.apiKey !== payload.apiKey);
return clone(state.coreConfig);
}
case 'set_core_management_secret_key':
case 'clear_core_management_secret_key': return clone(state.coreConfig);
case 'set_core_management_secret_key': {
state.coreConfig.managementSecretConfigured = true;
return clone(state.coreConfig);
}
case 'clear_core_management_secret_key': {
state.coreConfig.managementSecretConfigured = false;
return clone(state.coreConfig);
}
case 'check_app_update': return clone(state.appUpdateInfo);
case 'get_app_update_task': return clone(state.appUpdateTask);
+52 -21
View File
@@ -46,6 +46,8 @@ import codexIcon from '../assets/icons/codex.svg';
import deepseekIcon from '../assets/icons/deepseek.svg';
import geminiIcon from '../assets/icons/gemini.svg';
import openaiIcon from '../assets/icons/openai-light.svg';
import vertexIcon from '../assets/icons/vertex.svg';
import grokIcon from '../assets/icons/grok.svg';
import {
isRecord,
managementApi,
@@ -77,9 +79,11 @@ import {
import { modelMatchesRule } from '../services/oauthModels';
import { normalizeProviderProxyUrl } from '../services/providerProxy';
import { ProviderGroupKeysEditor } from '../components/ProviderGroupKeysEditor';
import { ProviderGroupTemplateFields, ProviderModelFields } from '../components/ProviderTemplateFields';
import { providerText } from '../i18n/providerTemplate';
import {
cleanProviderGroup, effectiveProviderKey, providerGroupIdentity, providerGroupKeys, providerGroupStatus,
providerKeyDraft, serializeProviderKey, validateProviderGroupKeys, type ProviderKeyDraft,
providerKeyDraft, serializeProviderKey, validateProviderGroupKeys, validateProviderTemplateRecord, type ProviderKeyDraft,
} from '../services/providerGroups';
import { providerGroupsApi } from '../services/managementApi';
import { getCurrentLocale, translate, useI18n } from '../i18n';
@@ -88,6 +92,10 @@ import { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice';
export type ProviderSection =
| 'gemini-api-key'
| 'interactions-api-key'
| 'vertex-api-key'
| 'xai-api-key'
| 'meta-api-key'
| 'codex-api-key'
| 'claude-api-key'
| 'openai-compatibility';
@@ -102,6 +110,7 @@ type ProviderDefinition = {
section: ProviderSection;
responseKey: string;
labelKey: MessageKey;
label?: string;
icon: string;
openAi: boolean;
};
@@ -212,6 +221,7 @@ const requestErrorMessage = (error: unknown) =>
error instanceof Error ? error.message : String(error);
export type ProviderDraft = {
templateFields?: Record<string, unknown>;
groupKeys?: ProviderKeyDraft[];
name: string;
apiKey: string;
@@ -257,8 +267,14 @@ const providerDefinitions: ProviderDefinition[] = [
},
{ id: 'claude-api-key', section: 'claude-api-key', responseKey: 'claude-api-key', labelKey: 'apiAccess.provider.claude', icon: claudeIcon, openAi: false },
{ id: 'gemini-api-key', section: 'gemini-api-key', responseKey: 'gemini-api-key', labelKey: 'apiAccess.provider.gemini', icon: geminiIcon, openAi: false },
{ id: 'interactions-api-key', section: 'interactions-api-key', responseKey: 'interactions-api-key', labelKey: 'apiAccess.provider.gemini', label: 'Gemini Interactions', icon: geminiIcon, openAi: false },
{ id: 'vertex-api-key', section: 'vertex-api-key', responseKey: 'vertex-api-key', labelKey: 'apiAccess.provider.gemini', label: 'Vertex AI', icon: vertexIcon, openAi: false },
{ id: 'xai-api-key', section: 'xai-api-key', responseKey: 'xai-api-key', labelKey: 'apiAccess.provider.codex', label: 'xAI', icon: grokIcon, openAi: false },
{ id: 'meta-api-key', section: 'meta-api-key', responseKey: 'meta-api-key', labelKey: 'apiAccess.provider.codex', label: 'Meta', icon: openaiIcon, openAi: false },
];
const providerLabel = (definition: ProviderDefinition) => definition.label ?? translate(getCurrentLocale(), definition.labelKey);
export const providerSectionOrder = providerDefinitions.map((definition) => definition.id);
const providerLoadDefinitions = providerDefinitions.filter(
@@ -268,6 +284,10 @@ const providerLoadDefinitions = providerDefinitions.filter(
const emptyRecords = (): Record<ProviderSection, Record<string, unknown>[]> => ({
'gemini-api-key': [],
'interactions-api-key': [],
'vertex-api-key': [],
'xai-api-key': [],
'meta-api-key': [],
'codex-api-key': [],
'claude-api-key': [],
'openai-compatibility': [],
@@ -327,12 +347,12 @@ const rowFromRecord = (
record,
name: grouped || definitionFor(section).openAi
? readString(record, 'name') || translate(getCurrentLocale(), 'apiAccess.compatibleName', { number: index + 1 })
: translate(getCurrentLocale(), definitionFor(section).labelKey),
: providerLabel(definitionFor(section)),
apiKey: entry ? readString(entry, 'api-key', 'apiKey') : singleApiKey,
apiKeys: entry ? apiKeys : singleApiKey ? [singleApiKey] : [],
baseUrl: readString(record, 'base-url', 'baseUrl'),
models: grouped && Array.isArray(record.models)
? record.models.flatMap((model) => modelsFromRecord([model])) : modelsFromRecord(record.models),
? record.models.flatMap((model) => modelsFromRecord([model], true)) : modelsFromRecord(record.models, true),
disabled: grouped ? providerGroupStatus(record) === 'disabled' : definitionFor(section).openAi
? readBoolean(record, 'disabled')
: excludedModels.some((model) => model.trim() === '*'),
@@ -380,6 +400,10 @@ const providerModelType = (
record?: Record<string, unknown>,
): ModelProvider => {
if (section === 'gemini-api-key') return 'gemini';
if (section === 'interactions-api-key') return 'interactions';
if (section === 'vertex-api-key') return 'vertex';
if (section === 'xai-api-key') return 'xai';
if (section === 'meta-api-key') return 'meta';
if (section === 'claude-api-key') return 'claude';
if (section === 'codex-api-key' && record && isDeepSeekRecord(record)) return 'deepseek';
if (section === 'codex-api-key') return 'codex';
@@ -491,7 +515,7 @@ const mergeModelRecords = (current: unknown, selected: ModelOption[]) => {
}
if (matchedIndex >= 0) consumedExistingIndexes.add(matchedIndex);
const matched = matchedIndex >= 0 ? existing[matchedIndex] : undefined;
const next: Record<string, unknown> = isRecord(matched) ? { ...matched } : {};
const next: Record<string, unknown> = model.config ? { ...model.config } : isRecord(matched) ? { ...matched } : {};
next.name = name;
const storedAlias = readString(next, 'alias');
const alias = usableModelAlias(requested);
@@ -644,6 +668,8 @@ const draftFromRow = (row: ProviderRow): ProviderDraft => {
const definition = definitionFor(row.section);
const isDeepSeek = row.section === 'codex-api-key' && isDeepSeekRecord(row.record);
return {
templateFields: Object.fromEntries(['request-retry', 'request-scoped-errors', 'support-prompt-cache-key']
.filter((field) => Object.prototype.hasOwnProperty.call(row.record, field)).map((field) => [field, structuredClone(row.record[field])])),
...(Array.isArray(row.record.keys) ? { groupKeys: providerGroupKeys(row.record).map(providerKeyDraft) } : {}),
name: Array.isArray(row.record.keys) ? row.name : isDeepSeek ? 'DeepSeek' : row.name,
apiKey: definition.openAi ? row.apiKeys.join('\n') : row.apiKey,
@@ -685,6 +711,7 @@ export const providerDraftFromRecord = (section: ProviderSection, record: Record
draftFromRow(rowFromRecord(section, record, 0));
const emptyProviderDraft = (): ProviderDraft => ({
templateFields: {},
name: '',
apiKey: '',
remark: '',
@@ -945,6 +972,12 @@ export const buildProviderGroupRecord = (
if (draft.proxyUrl?.trim()) next['proxy-url'] = draft.proxyUrl.trim();
else delete next['proxy-url'];
next.keys = (draft.groupKeys ?? []).map(serializeProviderKey);
if (draft.templateFields) {
for (const field of ['request-retry', 'request-scoped-errors', 'support-prompt-cache-key']) {
if (Object.prototype.hasOwnProperty.call(draft.templateFields, field)) next[field] = structuredClone(draft.templateFields[field]);
else delete next[field];
}
}
// Preserve absent/null/empty fields when the corresponding shared setting was
// not edited. In v8 these values can have different inheritance semantics.
if (current) {
@@ -962,6 +995,8 @@ export const buildProviderGroupRecord = (
else delete next[configField];
}
}
const invalid = validateProviderTemplateRecord(next);
if (invalid) throw new Error(`${providerText('invalid')}${invalid}`);
return next;
};
@@ -1121,7 +1156,7 @@ export function ApiAccessPage() {
if (result.status === 'fulfilled') {
next[result.value.section] = result.value.records;
} else {
failures.push(`${t(definition.labelKey)}: ${String(result.reason)}`);
failures.push(`${providerLabel(definition)}: ${String(result.reason)}`);
}
});
return next;
@@ -1265,7 +1300,7 @@ export function ApiAccessPage() {
throw new Error(t('apiAccess.error.proxyUrlInvalid'));
}
}
if (baseUrlRequired && !baseUrl) throw new Error(t('apiAccess.error.baseRequired', { provider: t(definition.labelKey) }));
if (baseUrlRequired && !baseUrl) throw new Error(t('apiAccess.error.baseRequired', { provider: providerLabel(definition) }));
providerHeaders = parseProviderHeaders(preparedDraft.headersText ?? '');
} catch (requestError) {
return { saved: false, target: 'form', error: requestErrorMessage(requestError) };
@@ -1485,7 +1520,7 @@ export function ApiAccessPage() {
disabled={busy}
>
<img src={definition.icon} alt="" className="provider-logo" />
<span title={t(definition.labelKey)}>{t(definition.labelKey)}</span>
<span title={providerLabel(definition)}>{providerLabel(definition)}</span>
<strong>{countForDefinition(definition)}</strong>
</button>
))}
@@ -1494,7 +1529,7 @@ export function ApiAccessPage() {
<section className="panel provider-resource-panel">
<div className="management-panel-heading">
<div>
<h2 title={t(activeDefinition.labelKey)}>{t(activeDefinition.labelKey)}</h2>
<h2 title={providerLabel(activeDefinition)}>{providerLabel(activeDefinition)}</h2>
<span>{t('apiAccess.groups.summary', { count: rows.length })}</span>
</div>
<div className="management-toolbar compact-toolbar api-access-search">
@@ -2023,15 +2058,7 @@ export function ApiProviderDialog({
setModelLoading(true);
setModelError('');
try {
const provider: ModelProvider = activeCategory === 'deepseek'
? 'deepseek'
: definition.section === 'gemini-api-key'
? 'gemini'
: definition.section === 'claude-api-key'
? 'claude'
: definition.section === 'codex-api-key'
? 'codex'
: 'openai';
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 modelApiKey = connection ? readString(connection, 'api-key') : draft.apiKey.split(/\r?\n/).map((value) => value.trim()).find(Boolean) ?? '';
@@ -2178,10 +2205,10 @@ export function ApiProviderDialog({
/>
</label> : null}
<label><span>{t('apiAccess.field.baseUrl')}</span><input value={draft.baseUrl} onChange={(event) => updateTextField('baseUrl', event.currentTarget.value)} placeholder={activeSection === 'codex-api-key' || activeSection === 'openai-compatibility' ? t('apiAccess.baseRequiredPlaceholder') : t('apiAccess.baseOptionalPlaceholder')} /></label>
<label><span>{t('apiAccess.field.proxyUrl')}</span><input value={draft.proxyUrl ?? ''} onChange={(event) => updateTextField('proxyUrl', event.currentTarget.value)} placeholder={draft.proxyUrlMixed ? t('apiAccess.proxyMixed') : 'socks5://127.0.0.1:1080'} /></label>
{!grouped || activeSection !== 'openai-compatibility' ? <label><span>{t('apiAccess.field.proxyUrl')}</span><input value={draft.proxyUrl ?? ''} onChange={(event) => updateTextField('proxyUrl', event.currentTarget.value)} placeholder={draft.proxyUrlMixed ? t('apiAccess.proxyMixed') : 'socks5://127.0.0.1:1080'} /></label> : null}
{draft.proxyUrlMixed ? <button type="button" className="secondary-button compact-button api-provider-proxy-clear" onClick={() => updateTextField('proxyUrl', '')}>{t('apiAccess.proxyClear')}</button> : null}
{draft.groupKeys ? <ProviderGroupKeysEditor keys={draft.groupKeys} section={activeSection} disabled={busy}
shared={{ models: draft.models, priority: draft.priority ? Number(draft.priority) : undefined, prefix: draft.prefix,
shared={{ ...draft.templateFields, models: draft.models.map((model) => ({ ...model.config, name: model.name, ...(model.alias ? { alias: model.alias } : {}), ...(model.thinking ? { thinking: model.thinking } : {}) })), priority: draft.priority ? Number(draft.priority) : undefined, prefix: draft.prefix,
'proxy-url': draft.proxyUrl, 'disable-cooling': draft.disableCooling,
'excluded-models': draft.excludedModelsText?.split(/[\n,]/).map((item) => item.trim()).filter(Boolean),
headers: (() => { try { return parseProviderHeaders(draft.headersText ?? ''); } catch { return {}; } })(),
@@ -2244,7 +2271,7 @@ export function ApiProviderDialog({
</div>
<div className="model-config-entries">
{draft.models.map((model, index) => (
<div className="model-config-entry" key={index}>
<div className="provider-model-config" key={index}><div className="model-config-entry">
<input
value={model.name}
onChange={(event) => updateModel(index, { name: event.currentTarget.value })}
@@ -2270,6 +2297,8 @@ export function ApiProviderDialog({
<Trash2 size={14} />
</button>
</div>
<ProviderModelFields section={activeSection} value={model.config ?? { ...(model.thinking ? { thinking: model.thinking } : {}) }} onChange={(config) => updateModel(index, { config, thinking: isRecord(config.thinking) ? config.thinking : undefined })} />
</div>
))}
<button type="button" className="secondary-button compact-button model-config-add" onClick={addModel} disabled={busy}>
<Plus size={14} />{t('apiAccess.models.add')}
@@ -2281,10 +2310,12 @@ export function ApiProviderDialog({
<details className="provider-advanced-settings">
<summary>{t('apiAccess.advanced')}</summary>
<div className="provider-advanced-fields">
<ProviderGroupTemplateFields section={activeSection} value={draft.templateFields ?? {}} onChange={(templateFields) => setDraft((current) => ({ ...current, templateFields }))} />
<label><span>{t('apiAccess.field.prefix')}</span><input value={draft.prefix ?? ''} onChange={(event) => updateTextField('prefix', event.currentTarget.value)} placeholder={t('apiAccess.prefixPlaceholder')} /></label>
<label className="multiline-field">
<span>{t('apiAccess.field.headers')}</span>
<textarea value={draft.headersText ?? ''} onChange={(event) => updateTextField('headersText', event.currentTarget.value)} rows={3} placeholder={'X-Team: production\nAuthorization: Bearer ...'} />
<small>{providerText('headersHint')}</small>
</label>
{activeSection !== 'openai-compatibility' ? (
<label className="multiline-field">
@@ -2351,7 +2382,7 @@ export function ApiProviderDialog({
<div className="model-discovery-backdrop" onMouseDown={(event) => event.currentTarget === event.target && closeModelDiscovery()}>
<section ref={modelDialogRef} className="model-discovery-dialog model-transfer-dialog" role="dialog" aria-modal="true" aria-labelledby="model-discovery-title">
<div className="model-discovery-header">
<div><h2 id="model-discovery-title">{t('apiAccess.modelDialog.title')}</h2><span>{t(definition.labelKey)}</span></div>
<div><h2 id="model-discovery-title">{t('apiAccess.modelDialog.title')}</h2><span>{providerLabel(definition)}</span></div>
<button type="button" className="icon-button quiet" onClick={closeModelDiscovery} title={t('common.close')} aria-label={t('common.close')}><X size={18} aria-hidden="true" /></button>
</div>
+76 -39
View File
@@ -38,8 +38,16 @@ import { ThinkingAliasesPage } from './ThinkingAliasesPage';
import { SensitiveWordsPage } from './SensitiveWordsPage';
import { handleHorizontalTabKey } from '../components/tabKeyboardNavigation';
import { useDialogFocusTrap } from '../components/useDialogFocusTrap';
import { useConfirmation } from '../components/ConfirmationDialog';
import { TemplateConfigSection } from '../components/TemplateConfigSection';
import { generalTemplateGroups, networkTemplateGroups, requestTemplateGroups, routingTemplateGroups } from '../services/generalTemplateFields';
import { oauthTemplateGroups } from '../services/oauthTemplateFields';
import { extensionTemplateGroups } from '../services/extensionTemplateFields';
import { payloadTemplateGroups } from '../services/payloadTemplateFields';
import { templateMessages, templateText } from '../i18n/templateConfig';
type CoreConfigSettings = {
managementSecretConfigured: boolean;
apiKeys: CoreApiKey[];
debug: boolean;
commercialMode: boolean;
@@ -81,8 +89,10 @@ type ConfigAction =
| 'tls'
| 'software'
| null;
type ConfigSubpage = 'general' | 'network' | 'routing' | 'software' | 'aliases' | 'sensitive-words';
const CONFIG_SUBPAGES: readonly ConfigSubpage[] = ['general', 'network', 'routing', 'aliases', 'software', 'sensitive-words'];
type ConfigSubpage = 'general' | 'network' | 'routing' | 'requests' | 'oauth' | 'extensions' | 'software' | 'aliases' | 'sensitive-words';
const CONFIG_SUBPAGES: readonly ConfigSubpage[] = ['general', 'network', 'routing', 'requests', 'oauth', 'extensions', 'aliases', 'software', 'sensitive-words'];
const TEMPLATE_GROUPS_BY_TAB = { general: generalTemplateGroups, network: networkTemplateGroups, routing: routingTemplateGroups, requests: [...requestTemplateGroups, ...payloadTemplateGroups], oauth: oauthTemplateGroups, extensions: extensionTemplateGroups, software: [], aliases: [], 'sensitive-words': [] };
const ALL_TEMPLATE_GROUPS = Object.values(TEMPLATE_GROUPS_BY_TAB).flat();
type CloseBehavior = 'ask' | 'exit' | 'minimize-to-tray';
type NetworkDraftField =
| 'port'
@@ -136,11 +146,13 @@ const cleanNetworkDraft = (): NetworkDraftDirty => ({
const ROUTING_OPTIONS = [
{ value: 'round-robin', labelKey: 'config.routing.roundRobin' },
{ value: 'weighted-round-robin', labelKey: 'config.routing.roundRobin' },
{ value: 'fill-first', labelKey: 'config.routing.fillFirst' },
] as const;
export function ConfigPanelPage() {
const { t } = useI18n();
const { t, locale } = useI18n();
const { askConfirmation, confirmationDialog } = useConfirmation();
const { status: coreStatus, publishStatus, refreshStatus } = useCoreRuntime();
const [settings, setSettings] = useState<CoreConfigSettings | null>(null);
const [softwareSettings, setSoftwareSettings] = useState<SoftwareSettings | null>(null);
@@ -211,6 +223,7 @@ export function ConfigPanelPage() {
const [retryError, setRetryError] = useState('');
const networkDraftDirtyRef = useRef<NetworkDraftDirty>(cleanNetworkDraft());
const loggingDraftDirtyRef = useRef(false);
const otherDraftDirtyRef = useRef({ software: false, tls: false });
const copyTimerRef = useRef<number | null>(null);
useEffect(() => {
@@ -222,8 +235,8 @@ export function ConfigPanelPage() {
void listen('config-files-changed', () => {
if (!disposed) {
void loadSettings('preserve');
void loadSoftwareSettings();
void loadTlsSettings();
void loadSoftwareSettings('preserve');
void loadTlsSettings('preserve');
}
}).then((unlisten) => {
if (disposed) unlisten();
@@ -319,12 +332,13 @@ export function ConfigPanelPage() {
}
}
async function loadSoftwareSettings() {
async function loadSoftwareSettings(mode: DraftRefreshMode = 'replace') {
setSoftwareSettingsLoading(true);
setSoftwareSavedStatusVisible(false);
try {
const result = await invoke<SoftwareSettings>('get_software_settings');
setSoftwareSettings(result);
if (mode === 'preserve' && otherDraftDirtyRef.current.software) return;
setSoftwareCloseBehaviorDraft(result.closeBehavior);
setSoftwareAutostartDraft(result.autostartEnabled);
setSoftwareStartCoreDraft(result.startCoreOnLaunch);
@@ -338,11 +352,12 @@ export function ConfigPanelPage() {
}
}
async function loadTlsSettings() {
async function loadTlsSettings(mode: DraftRefreshMode = 'replace') {
setTlsSettingsLoading(true);
try {
const result = await invoke<CoreTlsSettings>('get_core_tls_settings');
setTlsSettings(result);
if (mode === 'preserve' && otherDraftDirtyRef.current.tls) return;
setTlsEnabledDraft(result.enabled);
setTlsCertDraft(result.cert);
setTlsKeyDraft(result.key);
@@ -465,6 +480,18 @@ export function ConfigPanelPage() {
}
};
const disableManagement = async () => {
const confirmed = await askConfirmation({
title: templateText(templateMessages.disableManagement, locale),
message: templateText(templateMessages.disableManagementMessage, locale),
confirmText: templateText(templateMessages.disableManagement, locale),
variant: 'danger',
});
if (!confirmed) return;
const saved = await runMutation('management-secret', 'clear_core_management_secret_key', {}, templateText(templateMessages.managementDisabled, locale));
if (saved) { setManagementSecretDraft(''); setManagementSecretConfirm(''); setShowManagementSecret(false); setManagementSecretError(''); }
};
const submitApiKey = async (event: FormEvent) => {
event.preventDefault();
const apiKey = newApiKey.trim();
@@ -615,9 +642,11 @@ export function ConfigPanelPage() {
]);
applySettings(latestSettings, 'preserve');
setTlsSettings(latestTlsSettings);
setTlsEnabledDraft(latestTlsSettings.enabled);
setTlsCertDraft(latestTlsSettings.cert);
setTlsKeyDraft(latestTlsSettings.key);
if (!otherDraftDirtyRef.current.tls) {
setTlsEnabledDraft(latestTlsSettings.enabled);
setTlsCertDraft(latestTlsSettings.cert);
setTlsKeyDraft(latestTlsSettings.key);
}
await invoke('open_external_url', {
url: webUiManagementUrl(latestSettings.port, latestTlsSettings.enabled, latestSettings.host),
});
@@ -658,7 +687,7 @@ export function ConfigPanelPage() {
} catch (error) {
setTlsError(String(error));
void refreshStatus();
void loadTlsSettings();
void loadTlsSettings('preserve');
} finally {
setBusyAction(null);
}
@@ -708,11 +737,6 @@ export function ConfigPanelPage() {
networkFeedback.clearNotice();
if (!settings || busyAction !== null) return;
const host = hostDraft.trim();
if (!host) {
setHostError(t('config.error.hostRequired'));
networkFeedback.showNotice({ key: 'config.error.hostRequired' }, 'error');
return;
}
const port = Number(portDraft);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
setPortError(t('config.error.portRange'));
@@ -772,10 +796,10 @@ export function ConfigPanelPage() {
const retryValues = retryDrafts.map(Number);
if (
retryDrafts.some((value) => value.length === 0)
|| retryValues.some((value) => !Number.isInteger(value) || value < 0 || value > 4294967295)
|| retryValues.some((value, index) => !Number.isInteger(value) || value < (index === 2 ? -2147483648 : 0) || value > (index === 2 ? 2147483647 : 4294967295))
) {
setRetryError(t('config.error.retryRange'));
retryFeedback.showNotice({ key: 'config.error.retryRange' }, 'error');
setRetryError(templateText(templateMessages.retryRange, locale));
retryFeedback.showNotice(templateText(templateMessages.retryRange, locale), 'error');
return;
}
const [requestRetry, maxRetryCredentials, maxRetryInterval, streamingBootstrapRetries] = retryValues;
@@ -862,14 +886,9 @@ export function ConfigPanelPage() {
setSoftwareDefaultTerminalDraft(result.defaultTerminal);
setSoftwareSavedStatusVisible(true);
} catch (error) {
setSoftwareCloseBehaviorDraft(softwareSettings.closeBehavior);
setSoftwareAutostartDraft(softwareSettings.autostartEnabled);
setSoftwareStartCoreDraft(softwareSettings.startCoreOnLaunch);
setSoftwareSilentStartDraft(softwareSettings.silentStartEnabled);
setSoftwareDefaultTerminalDraft(softwareSettings.defaultTerminal);
setSoftwareSavedStatusVisible(false);
softwareFeedback.showNotice({ key: 'config.error.saveFailed', variables: { error: String(error) } }, 'error');
void loadSoftwareSettings();
void loadSoftwareSettings('preserve');
} finally {
setBusyAction(null);
}
@@ -935,6 +954,7 @@ export function ConfigPanelPage() {
|| tlsCertDraft.trim() !== tlsSettings.cert
|| tlsKeyDraft.trim() !== tlsSettings.key
);
otherDraftDirtyRef.current = { software: softwareSettingsDirty, tls: tlsSettingsDirty };
const tlsStatusLabel = tlsSettingsLoading
? t('common.loading')
: tlsSettings === null
@@ -980,6 +1000,7 @@ export function ConfigPanelPage() {
return (
<section className="page config-page">
{confirmationDialog}
<h1 className="sr-only">{t('app.nav.config')}</h1>
<div className="agent-subpage-tabs config-subpage-tabs" role="tablist" aria-label={t('config.tabs.label')}>
<button
@@ -1021,6 +1042,14 @@ export function ConfigPanelPage() {
>
{t('config.tabs.routing')}
</button>
{(['requests', 'oauth', 'extensions'] as const).map((subpage) => (
<button key={subpage} type="button" id={`config-subpage-tab-${subpage}`} role="tab"
className={activeSubpage === subpage ? 'active' : ''} aria-selected={activeSubpage === subpage}
aria-controls="config-template-panel" tabIndex={activeSubpage === subpage ? 0 : -1}
onClick={() => activateConfigSubpage(subpage)} onKeyDown={(event) => handleConfigTabKeyDown(event, subpage)}>
{templateText(templateMessages[subpage], locale)}
</button>
))}
<button
type="button"
id="config-subpage-tab-aliases"
@@ -1195,6 +1224,7 @@ export function ConfigPanelPage() {
<strong>{t('config.webuiKey.heading')}</strong>
<p>{t('config.webuiKey.description')}</p>
<small>{t('config.webuiKey.securityHint')}</small>
{settings?.managementSecretConfigured === false ? <p role="status">{templateText(templateMessages.managementDisabled, locale)}</p> : null}
</div>
<form
@@ -1260,6 +1290,9 @@ export function ConfigPanelPage() {
{managementSecretError || ' '}
</span>
<div className="config-management-actions">
<button type="button" className="secondary-button compact-button" disabled={controlsDisabled || settings?.managementSecretConfigured === false} onClick={() => void disableManagement()}>
{templateText(templateMessages.disableManagement, locale)}
</button>
<button
type="button"
className="secondary-button compact-button"
@@ -1292,9 +1325,7 @@ export function ConfigPanelPage() {
</div>
<p className="config-diagnostics-intro">
{t('config.diagnostics.description').split('**').map((part, index) => (
index % 2 === 1 ? <strong key={index}>{part}</strong> : part
))}
{templateText(templateMessages.diagnostics, locale)}
</p>
<div className="config-diagnostics-toggle-grid">
@@ -1539,7 +1570,7 @@ export function ConfigPanelPage() {
disabled={controlsDisabled}
placeholder="127.0.0.1"
aria-invalid={Boolean(hostError)}
title={hostError || t('config.network.listenHostHint')}
title={hostError || templateText(templateMessages.hostHint, locale)}
onChange={(event) => {
markDraftDirty('host');
setHostDraft(event.currentTarget.value);
@@ -1554,7 +1585,7 @@ export function ConfigPanelPage() {
}
}}
/>
<small>{t('config.network.listenHostHint')}</small>
<small>{templateText(templateMessages.hostHint, locale)}</small>
</label>
<div className="config-network-field config-proxy-field">
@@ -1687,7 +1718,7 @@ export function ConfigPanelPage() {
onClick={() => void changeRoutingStrategy(option.value)}
title={option.value}
>
{t(option.labelKey)}
{option.value === 'weighted-round-robin' ? templateText(templateMessages.weighted, locale) : t(option.labelKey)}
</button>
))}
</div>
@@ -1696,7 +1727,7 @@ export function ConfigPanelPage() {
? t('common.loading')
: settings === null
? t('common.unavailable')
: routingStrategyLabel(settings.routingStrategy, t)}
: routingStrategyLabel(settings.routingStrategy, t, locale)}
</small>
</div>
</div>
@@ -1770,7 +1801,7 @@ export function ConfigPanelPage() {
}
}}
/>
<small>{t('config.network.requestRetryHint')}</small>
<small>{templateText(templateMessages.retryHint, locale)}</small>
</label>
<label className="config-network-field">
@@ -1798,7 +1829,7 @@ export function ConfigPanelPage() {
}
}}
/>
<small>{t('config.network.maxRetryCredentialsHint')}</small>
<small>{templateText(templateMessages.credentialsHint, locale)}</small>
</label>
<label className="config-network-field">
@@ -1807,14 +1838,15 @@ export function ConfigPanelPage() {
className={`config-network-input ${retryError ? 'error' : ''}`}
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={10}
pattern="-?[0-9]*"
maxLength={11}
value={maxRetryIntervalDraft}
disabled={controlsDisabled}
aria-invalid={Boolean(retryError)}
onChange={(event) => {
markDraftDirty('maxRetryInterval');
setMaxRetryIntervalDraft(event.currentTarget.value.replace(/\D/g, '').slice(0, 10));
const raw = event.currentTarget.value;
setMaxRetryIntervalDraft(`${raw.startsWith('-') ? '-' : ''}${raw.replace(/\D/g, '').slice(0, 10)}`);
setRetryError('');
}}
onKeyDown={(event) => {
@@ -1826,7 +1858,7 @@ export function ConfigPanelPage() {
}
}}
/>
<small>{t('config.network.maxRetryIntervalHint')}</small>
<small>{templateText(templateMessages.waitHint, locale)}</small>
</label>
<label className="config-network-field">
@@ -2164,6 +2196,10 @@ export function ConfigPanelPage() {
</div>
) : null}
<div id="config-template-panel" role="tabpanel" aria-labelledby={`config-subpage-tab-${activeSubpage}`} hidden={TEMPLATE_GROUPS_BY_TAB[activeSubpage].length === 0}>
<TemplateConfigSection groups={ALL_TEMPLATE_GROUPS} visibleGroups={TEMPLATE_GROUPS_BY_TAB[activeSubpage].map((group) => group.id)} />
</div>
{sensitiveWordsVisited ? (
<div
className="config-subpage-panel"
@@ -2344,10 +2380,11 @@ function maskApiKey(apiKey: string) {
return `${value.slice(0, visible)}${'*'.repeat(Math.max(6, 10 - visible * 2))}${value.slice(-visible)}`;
}
function routingStrategyLabel(strategy: string | undefined, t: ReturnType<typeof useI18n>['t']) {
function routingStrategyLabel(strategy: string | undefined, t: ReturnType<typeof useI18n>['t'], locale: ReturnType<typeof useI18n>['locale']) {
if (!strategy) {
return t('common.loading');
}
const option = ROUTING_OPTIONS.find((item) => item.value === strategy);
if (strategy === 'weighted-round-robin') return templateText(templateMessages.weighted, locale);
return option ? t(option.labelKey) : strategy;
}
+23 -30
View File
@@ -26,9 +26,11 @@ import type { MessageKey } from "../i18n/resources";
import { MessageNotice, FloatingNotice, useAppNotice, type NoticeMessage } from "../appNotice";
import {
managementApi,
providerGroupsApi,
readString,
responseList,
} from "../services/managementApi";
import { appendNativeProviderGroup } from '../services/providerGroups';
import {
DEEPSEEK_BASE_URL,
fetchModels,
@@ -51,6 +53,7 @@ import devinIcon from "../assets/icons/devin.svg";
import openaiIcon from "../assets/icons/openai-light.svg";
import deepseekIcon from "../assets/icons/deepseek.svg";
import geminiIcon from "../assets/icons/gemini.svg";
import vertexIcon from '../assets/icons/vertex.svg';
type AuthMethod = "oauth" | "api";
type SetupStep = 1 | 2;
@@ -73,13 +76,14 @@ const oauthProviders: OAuthProviderInfo[] = [
{ id: "devin", name: "Devin OAuth", icon: devinIcon, descriptionKey: "easyMode.oauth.providerDesc.devin" },
];
type ApiSection = "openai-compatibility" | "deepseek" | "claude" | "gemini" | "codex";
type ApiManagementSection = "openai-compatibility" | "claude-api-key" | "codex-api-key" | "gemini-api-key";
type ApiSection = "openai-compatibility" | "deepseek" | "claude" | "gemini" | "codex" | 'interactions' | 'vertex' | 'xai' | 'meta';
type ApiManagementSection = "openai-compatibility" | "claude-api-key" | "codex-api-key" | "gemini-api-key" | 'interactions-api-key' | 'vertex-api-key' | 'xai-api-key' | 'meta-api-key';
type ApiSectionOption = {
id: ApiSection;
managementSection: ApiManagementSection;
nameKey: MessageKey;
name?: string;
provider: ModelProvider;
defaultBaseUrl: string;
icon: string;
@@ -91,6 +95,10 @@ const apiSectionOptions: ApiSectionOption[] = [
{ id: "codex", managementSection: "codex-api-key", nameKey: "easyMode.api.platformName.codex", provider: "codex", defaultBaseUrl: "", icon: codexIcon },
{ id: "gemini", managementSection: "gemini-api-key", nameKey: "easyMode.api.platformName.gemini", provider: "gemini", defaultBaseUrl: "", icon: geminiIcon },
{ id: "deepseek", managementSection: "codex-api-key", nameKey: "easyMode.api.platformName.deepseek", provider: "deepseek", defaultBaseUrl: DEEPSEEK_BASE_URL, icon: deepseekIcon },
{ id: 'interactions', managementSection: 'interactions-api-key', nameKey: 'easyMode.api.platformName.gemini', name: 'Gemini Interactions', provider: 'interactions', defaultBaseUrl: 'https://generativelanguage.googleapis.com', icon: geminiIcon },
{ id: 'vertex', managementSection: 'vertex-api-key', nameKey: 'easyMode.api.platformName.gemini', name: 'Vertex AI', provider: 'vertex', defaultBaseUrl: 'https://aiplatform.googleapis.com', icon: vertexIcon },
{ id: 'xai', managementSection: 'xai-api-key', nameKey: 'easyMode.api.platformName.openai', name: 'xAI', provider: 'xai', defaultBaseUrl: 'https://api.x.ai/v1', icon: grokIcon },
{ id: 'meta', managementSection: 'meta-api-key', nameKey: 'easyMode.api.platformName.openai', name: 'Meta', provider: 'meta', defaultBaseUrl: 'https://api.meta.ai/v1', icon: openaiIcon },
];
const isDeepSeekRecord = (record: Record<string, unknown>) => {
@@ -119,6 +127,7 @@ export function EasyModePage({
const [authFiles, setAuthFiles] = useState<Record<string, unknown>[]>([]);
const [apiCounts, setApiCounts] = useState<Record<ApiSection, number>>({
interactions: 0, vertex: 0, xai: 0, meta: 0,
"openai-compatibility": 0,
deepseek: 0,
claude: 0,
@@ -195,6 +204,7 @@ export function EasyModePage({
setAuthFiles(files);
const counts: Record<ApiSection, number> = {
interactions: 0, vertex: 0, xai: 0, meta: 0,
"openai-compatibility": 0,
deepseek: 0,
claude: 0,
@@ -202,13 +212,8 @@ export function EasyModePage({
codex: 0,
};
const configPayload = await managementApi.get("/config");
const recordsBySection: Record<ApiManagementSection, Record<string, unknown>[]> = {
"openai-compatibility": responseList(configPayload, "openai-compatibility"),
"claude-api-key": responseList(configPayload, "claude-api-key"),
"codex-api-key": responseList(configPayload, "codex-api-key"),
"gemini-api-key": responseList(configPayload, "gemini-api-key"),
};
const sections = [...new Set(apiSectionOptions.map((option) => option.managementSection))];
const recordsBySection = Object.fromEntries(await Promise.all(sections.map(async (section) => [section, await providerGroupsApi.get(section)]))) as Record<ApiManagementSection, Record<string, unknown>[]>;
for (const section of apiSectionOptions) {
const sourceList = recordsBySection[section.managementSection];
@@ -447,28 +452,16 @@ export function EasyModePage({
try {
const selectedOption = apiSectionOptions.find((option) => option.id === selectedApiSection);
const managementSection = selectedOption?.managementSection ?? "openai-compatibility";
const configPayload = await managementApi.get("/config");
const list = responseList(configPayload, managementSection);
const list = await providerGroupsApi.get(managementSection);
const selectedModels = apiSelectedModels.map((model) => ({ name: model.name.trim() }));
const models = selectedModels;
const newEntry = managementSection === "openai-compatibility"
? {
name: apiRemark.trim() || `${selectedApiSection} (${list.length + 1})`,
"base-url": normalizeBaseUrl(apiBaseUrl.trim()),
"api-key-entries": [
{ "api-key": apiKey.trim(), ...(proxyUrl ? { "proxy-url": proxyUrl } : {}) },
],
models,
}
: {
...(selectedApiSection === "deepseek" ? { name: "DeepSeek" } : {}),
"api-key": apiKey.trim(),
...(proxyUrl ? { "proxy-url": proxyUrl } : {}),
"base-url": normalizeBaseUrl(apiBaseUrl.trim()),
models,
};
await managementApi.put(`/${managementSection}`, [...list, newEntry]);
const newEntry = {
name: apiRemark.trim() || (selectedApiSection === 'deepseek' ? 'DeepSeek' : selectedApiSection),
'base-url': normalizeBaseUrl(apiBaseUrl.trim()),
keys: [{ 'api-key': apiKey.trim(), ...(proxyUrl ? { 'proxy-url': proxyUrl } : {}) }],
models,
};
await providerGroupsApi.put(managementSection, appendNativeProviderGroup(list, newEntry));
showApiNotice({ key: "easyMode.notice.apiSaveSuccess" });
setGuideApiSaved(true);
void refreshSourceStatus();
@@ -959,7 +952,7 @@ export function EasyModePage({
onClick={() => handleApiSectionChange(opt.id)}
>
<img className="simple-mode-api-platform-icon" src={opt.icon} alt="" />
{t(opt.nameKey)}
{opt.name ?? t(opt.nameKey)}
</button>
))}
</div>
+16
View File
@@ -3,6 +3,10 @@ import { isRecord, managementApi } from './managementApi';
import { normalizeAuthFilePriorityInput } from './authFiles';
import { authFileExcludedRulesFromPayload } from './oauthModelSettings';
import { normalizeOAuthExcludedRules } from './oauthModels';
import { credentialAdvancedShape } from './credentialAdvancedSettings';
import { validateStructuredValueText } from './structuredConfig';
import { templateText } from '../i18n/templateConfig';
import { sameTemplateValue } from './templateConfig';
export type BooleanOverride = '' | 'true' | 'false';
export type AuthFileSettingsDraft = {
@@ -15,6 +19,7 @@ export type AuthFileSettingsDraft = {
excluded_models: string;
headers: string;
note: string;
advanced: Record<string, unknown>;
};
const fail = (key: 'metadata' | 'headers' | 'weight' | 'priority') => {
@@ -68,6 +73,10 @@ export const authFileSettingsFromPayload = (payload: unknown): AuthFileSettingsD
excluded_models: authFileExcludedRulesFromPayload(metadata).join('\n'),
headers: JSON.stringify(headers, null, 2),
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]];
})),
};
};
@@ -109,6 +118,13 @@ export const buildAuthFileSettingsPatch = (
}
if (Object.keys(headers).length) patch.headers = headers;
}
for (const [key, shape] of Object.entries(credentialAdvancedShape.fields ?? {})) {
const next = draft.advanced[key];
if (sameTemplateValue(next, original.advanced[key])) continue;
const error = validateStructuredValueText(shape, next, key);
if (error) throw new Error(templateText(error, getCurrentLocale()));
patch[key] = next ?? null;
}
return patch;
};
@@ -0,0 +1,20 @@
import { boolShape, nonemptyText, stringsShape, textShape, type ConfigShape } from './structuredConfig';
export const credentialAdvancedShape: ConfigShape = { type: 'object', fields: {
cloak_mode: { type: 'select', optional: true, options: ['auto', 'always', 'never'], label: { zh: 'Claude 请求伪装', en: 'Claude request cloaking', ja: 'Claude リクエスト偽装' } },
cloak_strict_mode: boolShape({ zh: '严格伪装模式', en: 'Strict cloaking', ja: '厳格な偽装' }),
cloak_cache_user_id: boolShape({ zh: '复用用户标识', en: 'Cache user ID', ja: 'ユーザー ID を再利用' }),
cloak_sensitive_words: stringsShape({ zh: '伪装时处理的敏感词', en: 'Words to obfuscate when cloaking', ja: '偽装時に難読化する単語' }),
fingerprint_profile: { type: 'select', optional: true, options: ['', 'claude-code-cli', 'oauth-cli'], label: { zh: '请求指纹', en: 'Request fingerprint', ja: 'リクエストのフィンガープリント' } },
timezone: { ...textShape({ zh: '凭据时区', en: 'Credential timezone', ja: '認証情報のタイムゾーン' }), validate: value => {
if (!value) return null;
try { new Intl.DateTimeFormat('en', { timeZone: value as string }); return null; } catch { return { zh: '请输入 IANA 时区,例如 Asia/Shanghai', en: 'Enter an IANA timezone such as Asia/Shanghai', ja: 'Asia/Shanghai などの IANA タイムゾーンを入力してください' }; }
} },
model_aliases: { type: 'array', optional: true, label: { zh: '仅此凭据的模型别名', en: 'Aliases for this credential', ja: 'この認証情報のモデル別名' }, item: { type: 'object', fields: {
name: { ...nonemptyText, label: { zh: '上游模型', en: 'Upstream model', ja: '上流モデル' } },
alias: { ...nonemptyText, label: { zh: '客户端别名', en: 'Client alias', ja: 'クライアント別名' } },
'display-name': textShape({ zh: '展示名称', en: 'Display name', ja: '表示名' }),
fork: boolShape({ zh: '保留原始模型', en: 'Keep original model', ja: '元のモデルを維持' }),
'force-mapping': boolShape({ zh: '响应使用别名', en: 'Use alias in responses', ja: '応答で別名を使用' }),
} } },
} };
+66
View File
@@ -0,0 +1,66 @@
import type { TemplateConfigField, TemplateConfigGroup, TemplateText } from './templateConfig';
import { structuredField } from './payloadTemplateFields';
import { boolShape, configRecord, nonemptyText, textShape, type ConfigShape } from './structuredConfig';
const T = (zh: string, en: string, ja?: string): TemplateText => ({ zh, en, ja });
const envName: ConfigShape = { type: 'string', optional: true, validate: value => typeof value === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value) ? null : T('请输入环境变量名称', 'Enter an environment variable name', '環境変数名を入力してください') };
export const pluginStoreAuthShape: ConfigShape = { type: 'array', item: { type: 'object', fields: {
match: { ...nonemptyText, label: T('仓库或文件 URL 前缀', 'Registry or artifact URL prefix', 'レジストリ / アーティファクト URL プレフィックス'), validate: value => {
try { const url = new URL(value as string); return /^https?:$/.test(url.protocol) ? null : T('请使用 HTTP(S) 地址', 'Use an HTTP(S) URL', 'HTTP(S) の URL を使用してください'); } catch { return T('请输入有效的 HTTP(S) 地址', 'Use a valid HTTP(S) URL', '有効な HTTP(S) の URL を入力してください'); }
} },
'apply-to': { type: 'array', optional: true, item: { type: 'select', options: ['registry', 'artifact', 'metadata'] }, label: T('应用范围', 'Apply to', '適用先'),
hint: T('未设置或空列表应用于仓库、文件下载和元数据。', 'Unset or empty applies to registries, artifacts and metadata.', '未設定または空のリストはレジストリ、アーティファクト、メタデータすべてに適用します。') },
type: { type: 'select', optional: true, options: ['none', 'bearer', 'basic', 'header', 'github-token'], label: T('认证方式', 'Authentication type', '認証方式'),
hint: T('未设置或 none 不发送认证信息。', 'Unset or none sends no authentication.', '未設定または none では認証情報を送信しません。') },
'token-env': { ...envName, label: T('Token 环境变量', 'Token environment variable', 'トークン環境変数') },
'username-env': { ...envName, label: T('用户名环境变量', 'Username environment variable', 'ユーザー名の環境変数') },
'password-env': { ...envName, label: T('密码环境变量', 'Password environment variable', 'パスワード環境変数') },
'header-name': textShape(T('自定义请求头名', 'Custom header name', 'カスタムヘッダー名')),
'header-value-env': { ...envName, label: T('请求头值环境变量', 'Header value environment variable', 'ヘッダー値の環境変数') },
'allow-insecure': boolShape(T('允许通过 HTTP 发送认证信息', 'Allow authentication over HTTP', 'HTTP 経由の認証を許可')),
}, validate: value => {
if (!configRecord(value)) return T('认证规则无效', 'Invalid authentication rule', '認証ルールが無効です');
if (value.type == null || value.type === 'none') return null;
const required = value.type === 'basic' ? ['username-env', 'password-env'] : value.type === 'header' ? ['header-name', 'header-value-env'] : ['token-env'];
return required.every(key => typeof value[key] === 'string' && (value[key] as string).trim()) ? null : T(`请填写:${required.join(', ')}`, `Required: ${required.join(', ')}`, `必須項目:${required.join(', ')}`);
} } };
const duration = (block: string, key: string, label: TemplateText, placeholder: string): TemplateConfigField => ({
path: ['credentials', block, key], type: 'string', label, placeholder,
validate: value => typeof value === 'string' && /^(?:\d+(?:\.\d+)?(?:ns|us|µs|ms|s|m|h))+$/.test(value) ? null : T('请输入有效时长,如 250ms、5s 或 1m', 'Enter a duration such as 250ms, 5s or 1m', '250ms、5s、1m などの時間を入力してください'),
});
const limit = (block: string, key: string, label: TemplateText, placeholder: string): TemplateConfigField => ({ path: ['credentials', block, key], type: 'number', min: 0, label, placeholder });
export const extensionTemplateGroups: TemplateConfigGroup[] = [
{ id: 'extensions-plugins', title: T('原生插件', 'Native plugins', 'ネイティブプラグイン'),
description: T('插件在内核进程中运行。仓库认证只引用环境变量;单个插件的 enabled 不会隐式开启全局开关。插件配置可按实际插件字段添加,Home 管理的版本号不会被修改。', 'Plugins run inside the core process. Store authentication references environment variables. A plugin’s enabled setting does not enable the global switch. Configure fields supported by each plugin; Home-owned revisions remain unchanged.', 'プラグインはコア内で動作します。認証は環境変数を参照。個別の enabled は全体のスイッチを変更しません。Home の管理番号は維持します。'), fields: [
{ path: ['plugins', 'enabled'], type: 'boolean', defaultValue: false, label: T('启用原生插件', 'Enable native plugins', 'ネイティブプラグインを有効化') },
{ path: ['plugins', 'dir'], type: 'string', placeholder: 'plugins', label: T('插件目录', 'Plugin directory', 'プラグインディレクトリ') },
{ path: ['plugins', 'store-sources'], type: 'string-list', label: T('附加插件仓库', 'Additional plugin registries', '追加のプラグインレジストリ'), description: T('每行一个 registry.json URL;内置官方仓库始终保留。', 'One registry.json URL per line. The built-in official registry is always included.', '1 行に registry.json の URL を入力。公式レジストリは常に含まれます。') },
structuredField(['plugins', 'store-auth'], T('仓库认证规则', 'Registry authentication', 'レジストリ認証'), pluginStoreAuthShape),
structuredField(['plugins', 'configs'], T('各插件配置', 'Per-plugin configuration', 'プラグインごとの設定'), { type: 'map', item: { type: 'map', item: { type: 'any' } } }),
] },
{ id: 'extensions-concurrency', title: T('凭据并发策略(独立模式)', 'Credential concurrency (standalone mode)', '認証情報の同時実行(単独モード)'),
description: T('只用于独立运行的内核。Home 模式使用 Home 下发的策略,本地配置不生效;系统维护的生命周期和观察版本号不在此编辑。', 'Applies only to standalone cores. In Home mode, Home owns the policy and local values are ignored. System-owned lifecycle and observation revisions are not editable here.', '単独モード専用です。Home モードでは Home のポリシーが優先され、ローカル値は無視されます。システム管理のリビジョンは編集しません。'), fields: [
duration('concurrency', 'cpa-heartbeat-timeout', T('内核心跳超时', 'Core heartbeat timeout', 'コアのハートビートタイムアウト'), '3s'),
duration('concurrency', 'cpa-cancel-bound', T('取消等待上限', 'Cancellation bound', 'キャンセルの上限時間'), '5s'),
duration('concurrency', 'reclaim-grace', T('回收宽限时间', 'Reclaim grace period', '回収の猶予時間'), '5s'),
duration('concurrency', 'cleanup-interval', T('清理间隔', 'Cleanup interval', 'クリーンアップ間隔'), '5s'),
duration('concurrency', 'release-flush-interval', T('释放批次间隔', 'Release flush interval', '解放フラッシュ間隔'), '250ms'),
duration('concurrency', 'release-max-backoff', T('释放最大退避时间', 'Maximum release backoff', '解放の最大バックオフ'), '2s'),
duration('concurrency', 'busy-retry-min', T('忙碌重试最短等待', 'Minimum busy retry delay', 'ビジー時の最小再試行時間'), '250ms'),
duration('concurrency', 'busy-retry-max', T('忙碌重试最长等待', 'Maximum busy retry delay', 'ビジー時の最大再試行時間'), '1s'),
limit('concurrency', 'max-limit', T('最大并发上限', 'Maximum concurrency limit', '同時実行数の上限'), '1000000'),
] },
{ id: 'extensions-inflight', title: T('进行中请求观察', 'In-flight request observation', '処理中のリクエスト観測'), fields: [
duration('in-flight', 'snapshot-interval', T('快照间隔', 'Snapshot interval', 'スナップショット間隔'), '2s'),
duration('in-flight', 'stale-after', T('数据过期时间', 'Stale after', 'データの有効時間'), '10s'),
duration('in-flight', 'staging-retention', T('暂存保留时间', 'Staging retention', 'ステージング保持時間'), '1m'),
limit('in-flight', 'max-part-bytes', T('分片最大字节数', 'Maximum bytes per part', 'パートごとの最大バイト数'), '262144'),
limit('in-flight', 'max-part-count', T('最大分片数', 'Maximum part count', '最大パート数'), '64'),
limit('in-flight', 'max-revision-bytes', T('单次版本最大字节数', 'Maximum bytes per revision', 'リビジョンごとの最大バイト数'), '16777216'),
limit('in-flight', 'max-aggregate-groups', T('最大聚合分组数', 'Maximum aggregate groups', '最大集約グループ数'), '100000'),
limit('in-flight', 'max-details', T('最大明细数量', 'Maximum detail entries', '最大詳細数'), '10000'),
limit('in-flight', 'max-string-bytes', T('字符串最大字节数', 'Maximum string bytes', '文字列の最大バイト数'), '256'),
] },
];
+87
View File
@@ -0,0 +1,87 @@
import type { TemplateConfigField, TemplateConfigGroup } from './templateConfig';
const text = (zh: string, en: string, ja: string) => ({ zh, en, ja });
const field = (path: string, type: TemplateConfigField['type'], zh: string, en: string, ja: string, defaultValue: unknown, description?: ReturnType<typeof text>, extra: Partial<TemplateConfigField> = {}): TemplateConfigField => ({ path: path.split('.'), type, label: text(zh, en, ja), defaultValue, description, ...extra });
export const generalTemplateGroups: readonly TemplateConfigGroup[] = [
{
id: 'management', title: text('管理接口与面板', 'Management API & panel', '管理 API とパネル'),
fields: [
field('management.allow-remote', 'boolean', '允许远程管理', 'Allow remote management', 'リモート管理を許可', false, text('允许非本机访问管理接口,仍需管理密钥。', 'Allow non-local management requests; a management key is still required.', '他の端末からの管理 API アクセスを許可します。管理キーは引き続き必要です。')),
field('management.disable-control-panel', 'boolean', '禁用内置管理面板', 'Disable bundled control panel', '内蔵管理パネルを無効化', false),
field('management.disable-auto-update-panel', 'boolean', '禁用面板自动更新', 'Disable automatic panel updates', 'パネルの自動更新を無効化', false, text('缺少面板时仍会在首次访问时下载。', 'A missing panel is still downloaded on first access.', 'パネルが存在しない場合は、初回アクセス時にダウンロードします。')),
field('management.panel-github-repository', 'string', '管理面板仓库', 'Panel GitHub repository', '管理パネルの GitHub リポジトリ', 'https://github.com/router-for-me/Cli-Proxy-API-Management-Center', text('支持 GitHub 仓库 URL 或 releases API URL。', 'Accepts a GitHub repository URL or releases API URL.', 'GitHub リポジトリ URL または releases API URL を指定できます。')),
field('management.base-url', 'string', 'TUI 管理接口地址', 'TUI management base URL', 'TUI 管理 API のベース URL', '', text('仅用于内核 TUI 客户端模式,不更改桌面应用的连接地址。留空使用本机端口。', 'Used only by the core TUI client. Does not change the desktop connection. Leave blank to use the local port.', 'コアの TUI クライアントモード専用です。デスクトップアプリの接続先は変わりません。空欄ではローカルポートを使用します。')),
],
},
{
id: 'diagnostics', title: text('请求日志与诊断', 'Request logging & diagnostics', 'リクエストログと診断'),
description: text('应用日志、请求报文日志和用量统计独立配置。', 'Application logs, request/response logs and usage aggregation are configured separately.', 'アプリログ、リクエストと応答のログ、使用量集計は個別に設定します。'),
fields: [
field('observability.logs.request-log', 'boolean', '记录请求与响应', 'Log requests and responses', 'リクエストと応答を記録', false, text('请求日志可能包含请求与响应内容。', 'Request logs may include request and response content.', 'ログにはリクエストや応答の内容が含まれる場合があります。')),
field('observability.pprof.enable', 'boolean', '启用性能诊断服务', 'Enable pprof server', 'pprof サーバーを有効化', false),
field('observability.pprof.addr', 'string', '性能诊断监听地址', 'pprof listen address', 'pprof の待ち受けアドレス', '127.0.0.1:8316'),
],
},
];
export const networkTemplateGroups: readonly TemplateConfigGroup[] = [
{
id: 'trusted-proxies', title: text('可信代理', 'Trusted proxies', '信頼するプロキシ'),
fields: [field('server.trusted-proxies', 'string-list', '可信代理 IP / CIDR', 'Trusted proxy IPs / CIDRs', '信頼するプロキシの IP / CIDR', [], text('仅信任这些代理提供的客户端 IP 转发头。空列表表示不信任转发头;修改后需重启内核。', 'Only trust forwarded client IP headers from these proxies. An empty list trusts none. Restart the core after changing this setting.', '指定したプロキシからのクライアント IP 転送ヘッダーのみを信頼します。空のリストでは信頼しません。変更後はコアを再起動してください。'), { restart: true })],
},
{
id: 'discovery', title: text('局域网服务发现', 'Local network discovery', 'ローカルネットワークでのサービス検出'),
description: text('通过 mDNS / DNS-SD 广播服务,运行中的内核会自动应用修改。', 'Advertise this service through mDNS / DNS-SD. The running core applies changes automatically.', 'mDNS / DNS-SD でサービスを通知します。実行中のコアが変更を自動で適用します。'),
fields: [
field('server.discovery.enabled', 'boolean', '启用服务发现', 'Enable service discovery', 'サービス検出を有効化', false),
field('server.discovery.service-name', 'string', '服务名称前缀', 'Service name prefix', 'サービス名の接頭辞', '', text('留空使用 CPA;内核会自动追加短 ID。', 'Leave blank to use CPA. The core appends a short ID.', '空欄では CPA を使用します。コアが短い ID を追加します。')),
field('server.discovery.service-type', 'string', '服务类型', 'Service type', 'サービス種別', '_ai-gateway._tcp'),
field('server.discovery.subtypes', 'string-list', '广播的协议子类型', 'Advertised protocol subtypes', '通知するプロトコルのサブタイプ', undefined, text('未单独配置时由内核决定。示例:_chat-completions、_responses、_messages。', 'When unset, the core chooses subtypes. Examples: _chat-completions, _responses, _messages.', '未設定ではコアがサブタイプを選択します。例:_chat-completions、_responses、_messages。')),
field('server.discovery.interfaces.include', 'string-list', '允许的网卡', 'Included interfaces', '使用するネットワークインターフェース', [], text('空列表表示自动检测物理网卡。', 'An empty list automatically detects physical interfaces.', '空のリストでは物理ネットワークインターフェースを自動検出します。')),
field('server.discovery.interfaces.exclude', 'string-list', '排除的网卡模式', 'Excluded interface patterns', '除外するインターフェースのパターン', []),
field('server.discovery.auth-required', 'boolean', '广播需要身份验证', 'Advertise authentication requirement', '認証が必要なことを通知', true),
field('server.discovery.advertise-management', 'boolean', '广播管理接口可用性', 'Advertise management availability', '管理 API の利用可否を通知', false),
],
},
];
export const routingTemplateGroups: readonly TemplateConfigGroup[] = [
{
id: 'routing-advanced', title: text('会话与冷却策略', 'Session & cooldown behavior', 'セッションとクールダウンの動作'),
fields: [
field('routing.session-affinity-subagents', 'boolean', '子代理继承父会话凭据', 'Bind subagents to parent credentials', '子エージェントで親セッションの認証情報を使用', true, text('仅在启用会话粘性时生效。关闭后子代理按回退选择器分配。', 'Effective only with session affinity. When disabled, subagents use the fallback selector.', 'セッション固定が有効な場合のみ適用します。無効にすると子エージェントは代替選択方式を使用します。')),
field('routing.force-model-prefix', 'boolean', '强制模型前缀匹配', 'Enforce model prefix matching', 'モデル接頭辞の一致を必須にする', false, text('无前缀模型请求只使用无前缀凭据,前缀等于模型名的情况除外。', 'Unprefixed requests only use credentials without a prefix, except when prefix equals model name.', '接頭辞のないモデル要求には接頭辞のない認証情報のみを使用します。接頭辞とモデル名が一致する場合を除きます。')),
field('routing.cooldown.save-cooldown-status', 'boolean', '持久保存冷却状态', 'Persist cooldown status', 'クールダウン状態を永続化', false, text('将凭据冷却状态保存到认证文件旁的 .cds 文件。', 'Save per-credential cooldown state in .cds files next to auth files.', '認証情報ごとのクールダウン状態を認証ファイルと同じ場所の .cds ファイルに保存します。')),
field('routing.cooldown.transient-error-cooldown-seconds', 'number', '瞬时错误冷却秒数', 'Transient error cooldown (seconds)', '一時的なエラーのクールダウン秒数', 0, text('0 沿用 60 秒;-1 禁用瞬时错误冷却。', 'Zero uses the legacy 60 seconds; -1 disables transient error cooldown.', '0 は従来の 60 秒を使用し、-1 は一時的なエラーのクールダウンを無効化します。'), { min: -1, max: 2147483647 }),
],
},
];
export const requestTemplateGroups: readonly TemplateConfigGroup[] = [
{
id: 'request-behavior', title: text('请求与连接保持', 'Requests & keep-alives', 'リクエストと接続維持'), fields: [
field('requests.passthrough-headers', 'boolean', '透传上游响应头', 'Pass through upstream response headers', '上流の応答ヘッダーを転送', false, text('转发经过过滤的上游响应头。', 'Forward filtered upstream response headers.', 'フィルター済みの上流応答ヘッダーを転送します。')),
field('requests.nonstream-keepalive-interval', 'number', '非流式保活间隔(秒)', 'Non-streaming keep-alive interval (seconds)', '非ストリーミングの接続維持間隔(秒)', 0, text('大于 0 时定期发送空行,减少空闲超时。', 'When positive, periodically emit blank lines to prevent idle timeouts.', '正の値では定期的に空行を送信し、アイドルタイムアウトを防ぎます。'), { min: -2147483648, max: 2147483647 }),
field('requests.streaming.keepalive-seconds', 'number', '流式保活间隔(秒)', 'Streaming keep-alive interval (seconds)', 'ストリーミングの接続維持間隔(秒)', 0, text('0 或负数禁用。', 'Zero or negative disables keep-alives.', '0 または負の値で接続維持を無効化します。'), { min: -2147483648, max: 2147483647 }),
],
},
{
id: 'codex-client', title: text('Codex 客户端兼容', 'Codex client compatibility', 'Codex クライアントの互換性'), fields: [
field('client.codex.enable-apply-patch', 'boolean', '公开 apply_patch 工具能力', 'Advertise apply_patch support', 'apply_patch の対応状況を公開', false, text('在客户端模型目录中,将所有路由执行器均支持的模型标记为支持 freeform;关闭时清空 apply_patch_tool_type。', 'Advertise freeform in client model catalogs only for models supported by every routing executor. When disabled, clear apply_patch_tool_type.', 'すべてのルーティング実行器が対応するモデルのみ、クライアントのモデル一覧で freeform 対応を公開します。無効にすると apply_patch_tool_type を空にします。')),
field('client.codex.optimize-multi-agent-v2', 'boolean', '启用多代理优化 v2', 'Enable multi-agent optimization v2', 'マルチエージェント最適化 v2 を有効化', false, text('刷新 spawn_agent 模型说明、去除消息参数加密并规范化代理消息;同时适用于 OAuth 和 API 密钥请求。', 'Refresh spawn_agent model descriptions, remove message parameter encryption and normalize agent messages for both OAuth and API-key requests.', 'spawn_agent のモデル説明を更新し、メッセージパラメーターの暗号化を解除して、エージェントのメッセージを正規化します。OAuth と API キーの両方の要求に適用します。')),
],
},
{
id: 'multimedia', title: text('图像与视频', 'Images & video', '画像と動画'), fields: [
field('multimedia.disable-image-generation', 'select', '图像生成策略', 'Image generation policy', '画像生成の動作', false, undefined, { options: [
{ value: false, label: text('启用(默认)', 'Enabled (default)', '有効(既定)') },
{ value: true, label: text('禁用图像生成', 'Disable image generation', '画像生成を無効化') },
{ value: 'chat', label: text('仅保留图像接口', 'Image endpoints only', '画像 API のみ有効') },
{ value: 'passthrough', label: text('透传模式', 'Passthrough mode', 'そのまま転送') },
] }),
field('multimedia.gpt-image-2-base-model', 'string', 'GPT Image 2 基础模型', 'GPT Image 2 base model', 'GPT Image 2 の基本モデル', 'gpt-5.4-mini', text('留空使用 gpt-5.4-mini;自定义模型名称以 gpt- 开头。', 'Leave blank to use gpt-5.4-mini. Custom model names start with gpt-.', '空欄では gpt-5.4-mini を使用します。カスタムモデル名は gpt- で始まります。'), { validate: (value) => typeof value === 'string' && (!value || value.toLowerCase().startsWith('gpt-')) ? null : text('模型名称必须以 gpt- 开头,或留空使用默认值。', 'Model name must start with gpt-, or leave blank for the default.', 'gpt- で始まるモデル名を入力するか、空欄で既定値を使用してください。') }),
field('multimedia.video-result-auth-cache-ttl', 'string', '视频结果认证缓存时长', 'Video result auth cache TTL', '動画結果の認証キャッシュ有効期間', '3h', text('使用内核时长格式,例如 30m 或 3h。', 'Use a core duration such as 30m or 3h.', '30m や 3h など、コアの時間表記を使用してください。')),
],
},
];
+31 -18
View File
@@ -5,6 +5,7 @@ import { normalizeThinkingConfig } from './providerModels';
const modelText = (key: Parameters<typeof translate>[1]) => translate(getCurrentLocale(), key);
export type ModelOption = {
config?: Record<string, unknown>;
name: string;
alias?: string;
displayName?: string;
@@ -13,13 +14,29 @@ export type ModelOption = {
inputModalities?: Array<'text' | 'image'>;
thinking?: Record<string, unknown>;
};
export type ModelProvider = 'gemini' | 'codex' | 'deepseek' | 'claude' | 'openai';
export type ModelProvider = 'gemini' | 'interactions' | 'vertex' | 'xai' | 'meta' | 'codex' | 'deepseek' | 'claude' | 'openai';
export type ModelSelectionMode = 'initial' | 'refresh';
const DEFAULT_GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com';
const DEFAULT_CLAUDE_BASE_URL = 'https://api.anthropic.com';
export const DEEPSEEK_BASE_URL = 'https://api.deepseek.com';
// Meta's native executor sends this client identity on both model and inference requests.
export function providerDefaultHeaders(provider: ModelProvider): Record<string, string> {
return provider === 'meta' ? {
'User-Agent': 'muse-build/1.3.0 (interactive; macos-aarch64; build ac7280f2aca67769d1455a8847bb502b617d50f6)',
'X-Client-Id': 'tbh:tui',
} : {};
}
export const defaultProviderBaseUrl = (provider: ModelProvider): string => {
if (provider === 'gemini' || provider === 'interactions') return DEFAULT_GEMINI_BASE_URL;
if (provider === 'vertex') return 'https://aiplatform.googleapis.com';
if (provider === 'xai') return 'https://api.x.ai/v1';
if (provider === 'meta') return 'https://api.meta.ai/v1';
if (provider === 'claude') return DEFAULT_CLAUDE_BASE_URL;
if (provider === 'deepseek') return DEEPSEEK_BASE_URL;
return '';
};
const modelKey = (name: string) => name.trim().toLowerCase();
@@ -107,33 +124,27 @@ const stripKnownSuffix = (baseUrl: string) =>
.replace(/\/models$/i, '');
export const modelEndpointCandidates = (provider: ModelProvider, baseUrl: string): string[] => {
const resolvedBaseUrl = baseUrl.trim()
|| (provider === 'gemini'
? DEFAULT_GEMINI_BASE_URL
: provider === 'claude'
? DEFAULT_CLAUDE_BASE_URL
: provider === 'deepseek'
? DEEPSEEK_BASE_URL
: '');
const resolvedBaseUrl = baseUrl.trim() || defaultProviderBaseUrl(provider);
const normalized = normalizeBaseUrl(resolvedBaseUrl);
if (!normalized) return [];
if (provider === 'openai') {
if (provider === 'openai' || provider === 'xai' || provider === 'meta') {
return [/\/models$/i.test(normalized) ? normalized : `${normalized}/models`];
}
const base = stripKnownSuffix(normalized);
const withoutVersion = base.replace(/\/(?:v1beta|v1)$/i, '');
if (provider === 'gemini') return [`${withoutVersion}/v1beta/models`];
if (provider === 'gemini' || provider === 'interactions') return [`${withoutVersion}/v1beta/models`];
if (provider === 'vertex') return [`${withoutVersion}/v1/publishers/google/models`, `${base}/models`, `${withoutVersion}/v1/models`];
if (provider === 'claude') return [`${withoutVersion}/v1/models`];
if (provider === 'deepseek') return [`${base}/models`];
return [/\/v1$/i.test(base) ? `${base}/models` : `${base}/v1/models`];
};
const normalizeModelList = (payload: unknown, preserveExistingAlias = false): ModelOption[] => {
const normalizeModelList = (payload: unknown, preserveExistingAlias = false, preserveConfiguration = false): ModelOption[] => {
const parsed = typeof payload === 'string' ? (() => {
try { return JSON.parse(payload) as unknown; } catch { return payload; }
})() : payload;
const source = isRecord(parsed)
? (Array.isArray(parsed.data) ? parsed.data : Array.isArray(parsed.models) ? parsed.models : [])
? (Array.isArray(parsed.data) ? parsed.data : Array.isArray(parsed.models) ? parsed.models : Array.isArray(parsed.publisherModels) ? parsed.publisherModels : [])
: Array.isArray(parsed) ? parsed : [];
const seen = new Set<string>();
return source.map((item): ModelOption | null => {
@@ -149,6 +160,7 @@ const normalizeModelList = (payload: unknown, preserveExistingAlias = false): Mo
: undefined;
return {
name,
...(preserveConfiguration && record ? { config: { ...record, ...(thinking ? { thinking } : {}) } } : {}),
...(alias && alias !== name ? { alias } : {}),
...(displayName && displayName !== name ? { displayName } : {}),
...(thinking && (preserveExistingAlias || Object.keys(thinking).length > 0) ? { thinking } : {}),
@@ -156,9 +168,9 @@ const normalizeModelList = (payload: unknown, preserveExistingAlias = false): Mo
}).filter((item): item is ModelOption => item !== null);
};
export function modelsFromRecord(value: unknown): ModelOption[] {
export function modelsFromRecord(value: unknown, preserveConfiguration = false): ModelOption[] {
if (!Array.isArray(value)) return [];
return normalizeModelList(value, true);
return normalizeModelList(value, true, preserveConfiguration);
}
export function modelsFromDiscoveredPayload(payload: unknown): ModelOption[] {
@@ -179,10 +191,11 @@ export async function fetchModels(
const headers: Record<string, string> = { ...customHeaders };
const hasHeader = (name: string) =>
Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
for (const [name, value] of Object.entries(providerDefaultHeaders(provider))) if (!hasHeader(name)) headers[name] = value;
const headerValue = (name: string) =>
Object.entries(headers).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1] ?? '';
const key = apiKey.trim();
if (provider === 'gemini') {
if (provider === 'gemini' || provider === 'interactions' || provider === 'vertex') {
if (key && !hasHeader('x-goog-api-key')) headers['x-goog-api-key'] = key;
else if (authIndex && !hasHeader('x-goog-api-key')) headers['x-goog-api-key'] = '$TOKEN$';
} else if (provider === 'claude') {
@@ -204,7 +217,7 @@ export async function fetchModels(
const seen = new Set<string>();
let pageToken = '';
for (let page = 0; page < (provider === 'gemini' ? 20 : 1); page += 1) {
for (let page = 0; page < (['gemini', 'interactions', 'vertex'].includes(provider) ? 20 : 1); page += 1) {
const pageUrl = new URL(url);
if (pageToken) pageUrl.searchParams.set('pageToken', pageToken);
const response = await managementApi.post<Record<string, unknown>>('/api-call', {
@@ -221,7 +234,7 @@ export async function fetchModels(
const payload = response.body ?? response.bodyText;
modelsFromDiscoveredPayload(payload).forEach((model) => {
const name = provider === 'gemini' ? model.name.replace(/^models\//i, '') : model.name;
const name = ['gemini', 'interactions', 'vertex'].includes(provider) ? model.name.replace(/^(?:publishers\/google\/)?models\//i, '') : model.name;
const dedupeKey = name.toLowerCase();
if (!name || seen.has(dedupeKey)) return;
seen.add(dedupeKey);
+102
View File
@@ -0,0 +1,102 @@
import type { TemplateConfigField, TemplateConfigGroup, TemplateText } from './templateConfig';
import { structuredField } from './payloadTemplateFields';
import { boolShape, configRecord, errorRulesShape, nonemptyText, stringsShape, textShape, type ConfigShape } from './structuredConfig';
const T = (zh: string, en: string, ja?: string): TemplateText => ({ zh, en, ja });
const providerPath = (provider: string, key: string) => ['oauth', 'providers', provider, ...key.split('.')];
const flag = (provider: string, key: string, label: TemplateText, defaultValue = false, description?: TemplateText): TemplateConfigField =>
({ path: providerPath(provider, key), type: 'boolean', label, defaultValue, description });
const text = (provider: string, key: string, label: TemplateText, placeholder?: string): TemplateConfigField =>
({ path: providerPath(provider, key), type: 'string', label, placeholder });
const number = (provider: string, key: string, label: TemplateText, defaultValue: number, max?: number): TemplateConfigField =>
({ path: providerPath(provider, key), type: 'number', label, defaultValue, min: 0, max });
export const oauthAliasShape: ConfigShape = { type: 'array', item: { type: 'object', fields: {
name: { ...nonemptyText, label: T('上游模型', 'Upstream model', '上流モデル') },
alias: { ...nonemptyText, label: T('客户端别名', 'Client alias', 'クライアント別名') },
'display-name': textShape(T('展示名称', 'Display name', '表示名')),
fork: boolShape(T('保留原始模型名称', 'Keep the original model ID', '元のモデル ID を維持')),
'force-mapping': boolShape(T('将响应模型名改回别名', 'Map response model back to alias', '応答モデルを別名に変換')),
} } };
const modelSettingsShape: ConfigShape = { type: 'array', item: { type: 'object', fields: {
name: { ...nonemptyText, label: T('模型名称', 'Model name', 'モデル名') },
alias: { ...textShape(T('匹配客户端别名', 'Match client alias', 'クライアント別名を照合')),
hint: T('可选;精确别名匹配优先于模型名称匹配。', 'Optional. An exact alias match takes precedence over the model name.', '任意です。別名の完全一致はモデル名の一致より優先されます。') },
'max-context-length': { type: 'number', min: 0, optional: true, label: T('上下文窗口', 'Context window', 'コンテキストウィンドウ'),
hint: T('未设置或 0 使用原始模型能力。', 'Unset or zero uses the original model capability.', '未設定または 0 では元のモデル機能を使用します。') },
} } };
export const iceServersShape: ConfigShape = { type: 'array', item: { type: 'object', fields: {
urls: { type: 'array', item: { type: 'string', validate: value => typeof value === 'string' && /^(stun|stuns|turn|turns):\S+$/i.test(value) ? null : T('请使用 STUN 或 TURN 地址', 'Use a STUN or TURN URL', 'STUN または TURN の URL を指定してください') },
label: T('STUN / TURN 地址', 'STUN / TURN URLs', 'STUN / TURN URL'), validate: value => Array.isArray(value) && value.length ? null : T('请至少添加一个地址', 'At least one URL is required', 'URL を 1 件以上追加してください') },
username: textShape(T('TURN 用户名', 'TURN username', 'TURN ユーザー名')),
credential: { ...textShape(T('TURN 密钥', 'TURN credential', 'TURN 認証情報')), secret: true },
} } };
export const oauthTemplateGroups: TemplateConfigGroup[] = [
{ id: 'oauth-common', title: T('OAuth 通用设置', 'OAuth settings', 'OAuth 設定'), fields: [
{ path: ['oauth', 'auth-dir'], type: 'string', directory: true, restart: true, defaultValue: '~/.cli-proxy-api',
label: T('凭据文件目录', 'Credential files directory', '認証情報ファイルのフォルダー'),
description: T('支持 ~ 和相对路径;相对路径以内核安装目录为基准。修改不会移动已有凭据文件,重启内核后生效。恢复内核默认值使用 ~/.cli-proxy-api。', 'Supports ~ and paths relative to the core installation directory. Changing this path does not move existing credential files and takes effect after restarting the core. The core default is ~/.cli-proxy-api.', '~ と相対パスに対応します。相対パスの基準はコアのインストールフォルダーです。変更しても既存の認証情報ファイルは移動せず、コアの再起動後に適用されます。コアの既定値は ~/.cli-proxy-api です。'),
validate: (value) => typeof value === 'string' && value.trim().length > 0 && !/[\u0000-\u001f\u007f-\u009f]/.test(value) ? null : T('请输入非空且不含控制字符的目录路径。', 'Enter a non-empty directory path without control characters.', '制御文字を含まない、空でないフォルダーパスを入力してください。') },
{ path: ['oauth', 'auth-auto-refresh-workers'], type: 'number', min: 0, label: T('凭据自动刷新并发数', 'Credential refresh workers', '認証情報の更新ワーカー数'),
description: T('留空使用内核默认值;增加并发会同时刷新更多凭据。', 'Leave unset to use the kernel default. More workers refresh more credentials concurrently.', '未設定ではカーネルの既定値を使用します。') },
] },
{ id: 'oauth-models', title: T('OAuth 模型与通道', 'OAuth models and channels', 'OAuth モデルとチャンネル'),
description: T('按通道管理,例如 codex、claude、aistudio、vertex、antigravity、kimi、xai、meta;别名也支持插件通道。单凭据中的规则优先。', 'Manage channels such as codex, claude, aistudio, vertex, antigravity, kimi, xai and meta. Aliases also support plugin channels. Credential-specific rules take precedence.', '通道ごとに設定します。別名はプラグインのチャンネルにも対応し、認証情報ごとのルールが優先されます。'), fields: [
structuredField(['oauth', 'model-alias'], T('模型别名', 'Model aliases', 'モデル別名'), { type: 'map', item: oauthAliasShape }),
structuredField(['oauth', 'settings'], T('模型能力覆盖', 'Model capabilities', 'モデル機能の上書き'), { type: 'map', item: modelSettingsShape }),
structuredField(['oauth', 'excluded-models'], T('排除模型', 'Excluded models', '除外するモデル'), { type: 'map', item: stringsShape() }),
structuredField(['oauth', 'request-scoped-errors'], T('上游错误处理', 'Upstream error rules', '上流エラールール'), { type: 'map', item: errorRulesShape }),
] },
{ id: 'oauth-codex', title: T('Codex OAuth 行为', 'Codex OAuth behavior', 'Codex OAuth の動作'), fields: [
flag('codex', 'response-steering', T('双向 WebSocket 引导(实验性)', 'Full-duplex response steering (experimental)', '双方向レスポンス制御(実験的)')),
flag('codex', 'disable-codex-cloaking', T('禁用 Codex 客户端伪装', 'Disable Codex client cloaking', 'Codex 偽装を無効化')),
flag('codex', 'stream-bootstrap-buffering', T('缓冲流式启动事件', 'Buffer stream bootstrap events', 'ストリーム開始イベントをバッファー'), false,
T('在输出首个有效内容前保留响应头,使过载时可以切换凭据;可能增加首字延迟。', 'Hold headers until useful output arrives, allowing overload failover. This can delay the first token.', '有効な出力までヘッダーを保持し、過負荷時の切替を可能にします。開始が遅れる場合があります。')),
{ ...text('codex', 'stream-bootstrap-timeout', T('启动缓冲时限', 'Bootstrap buffering timeout', '開始バッファーの制限時間'), '20s'),
description: T('留空或 0 / none / unlimited / disabled / off / never 表示不限时;纯数字按秒计算。建议反向代理场景使用 20s。', 'Blank or 0 / none / unlimited / disabled / off / never means unlimited. Plain integers mean seconds. 20s is recommended behind a reverse proxy.', '空欄、0、none / unlimited / disabled / off / never は無制限。整数のみの場合は秒です。リバースプロキシでは 20s を推奨。'),
validate: value => value == null || typeof value === 'string' && (/^(?:\d*|none|unlimited|disabled|off|never)$/i.test(value.trim()) || /^(?:\d+(?:\.\d+)?(?:ns|us|µs|μs|ms|s|m|h))+$/.test(value.trim())) ? null : T('请输入有效时长,如 20s 或 20', 'Enter a duration such as 20s or 20', '20s または 20 などの時間を入力してください') },
flag('codex', 'orphan-delegation-compatibility', T('兼容孤立委派输出', 'Orphan delegation compatibility', '孤立した委譲出力の互換性')),
flag('codex', 'model-level-cooling', T('仅冷却发生限额的模型', 'Limit cooldown to the affected model', '対象モデルのみクールダウン')),
text('codex', 'header-defaults.user-agent', 'User-Agent'),
text('codex', 'header-defaults.beta-features', T('WebSocket Beta 功能', 'WebSocket beta features', 'WebSocket ベータ機能')),
] },
{ id: 'oauth-media', title: T('Codex 实时媒体中继', 'Codex live media relay', 'Codex ライブメディアリレー'),
description: T('代理实时音频和数据通道,需要可达的入站 UDP 端口。TURN 密钥从本地配置读取并保留。', 'Relay live audio and data channels. Requires reachable inbound UDP ports. TURN credentials are read from and preserved in the local configuration.', '音声とデータを中継します。受信 UDP ポートが必要です。TURN 認証情報はローカル設定から読み込み、保持します。'),
validate: config => {
const oauth = configRecord(config.oauth) ? config.oauth : {};
const providers = configRecord(oauth.providers) ? oauth.providers : {};
const codex = configRecord(providers.codex) ? providers.codex : {};
const relay = configRecord(codex['live-media-relay']) ? codex['live-media-relay'] : {};
const min = Number(relay['udp-port-min'] ?? 0), max = Number(relay['udp-port-max'] ?? 0), sessions = Number(relay['max-sessions'] || 32);
return min === 0 && max === 0 || min > 0 && max >= min && max - min + 1 >= sessions * 2 ? null
: T('UDP 起止端口需要同时设置,并为每个会话至少预留两个端口。', 'Set both UDP range endpoints and allow at least two ports per session.', 'UDP の開始・終了ポートを両方設定し、セッションごとに 2 ポート以上を確保してください。');
}, fields: [
flag('codex', 'live-media-relay.enabled', T('启用实时媒体中继', 'Enable live media relay', 'ライブメディアリレーを有効化')),
number('codex', 'live-media-relay.max-sessions', T('最大并发会话', 'Maximum concurrent sessions', '最大同時セッション数'), 32),
flag('codex', 'live-media-relay.disable-private-remote-ips', T('拒绝内网远端 IP', 'Reject private remote IPs', 'プライベート IP を拒否'), false,
T('本机或受信任局域网中的 Codex Desktop 通常应关闭此项。', 'Usually leave disabled for Codex Desktop on this machine or a trusted LAN.', '同じ端末や信頼する LAN の Codex Desktop では通常無効にします。')),
text('codex', 'live-media-relay.public-ip', T('对外公布的 IP', 'Advertised public IP', '公開 IP')),
number('codex', 'live-media-relay.udp-port-min', T('UDP 起始端口', 'First UDP port', 'UDP 開始ポート'), 0, 65535),
number('codex', 'live-media-relay.udp-port-max', T('UDP 结束端口', 'Last UDP port', 'UDP 終了ポート'), 0, 65535),
structuredField(providerPath('codex', 'live-media-relay.ice-servers'), T('STUN / TURN 服务器', 'STUN / TURN servers', 'STUN / TURN サーバー'), iceServersShape),
] },
{ id: 'oauth-claude', title: T('Claude OAuth 行为', 'Claude OAuth behavior', 'Claude OAuth の動作'), fields: [
flag('claude', 'model-level-cooling', T('仅冷却发生限额的模型', 'Limit cooldown to the affected model', '対象モデルのみクールダウン')),
flag('claude', 'disable-claude-cloak-mode', T('禁用 Claude 请求伪装', 'Disable Claude request cloaking', 'Claude リクエストの偽装を無効化')),
flag('claude', 'claude-code.disable-cloaking-model-list', T('返回原始模型列表', 'Return original model IDs', '元のモデル ID を返す')),
...[['user-agent', 'User-Agent'], ['package-version', T('软件包版本', 'Package version', 'パッケージバージョン')], ['runtime-version', T('运行时版本', 'Runtime version', 'ランタイムバージョン')], ['os', T('操作系统', 'Operating system', 'OS')], ['arch', T('处理器架构', 'Architecture', 'アーキテクチャ')], ['timeout', T('请求超时头', 'Request timeout header', 'タイムアウトヘッダー')], ['timezone', T('默认时区', 'Default timezone', '既定のタイムゾーン')]].map(([key, label]) => text('claude', `header-defaults.${key}`, label as TemplateText)),
flag('claude', 'header-defaults.stabilize-device-profile', T('固定凭据设备特征', 'Stabilize credential device profile', '端末プロファイルを固定')),
] },
{ id: 'oauth-others', title: T('其他 OAuth 上游', 'Other OAuth providers', 'その他の OAuth プロバイダー'), fields: [
flag('aistudio', 'ws-auth', T('AI Studio WebSocket 身份验证', 'AI Studio WebSocket authentication', 'AI Studio WebSocket 認証'), true),
flag('xai', 'inject-x-search', T('xAI 自动注入 X Search', 'Inject xAI X Search', 'xAI X Search を自動追加')),
flag('antigravity', 'antigravity-credits', T('Antigravity 耗尽免费配额后使用积分', 'Use Antigravity credits after free quota is exhausted', '無料枠を使い切った後に Antigravity クレジットを使用'), true),
flag('antigravity', 'signature-cache-enabled', T('Antigravity 签名缓存校验', 'Antigravity signature cache validation', 'Antigravity 署名キャッシュ検証'), true),
flag('antigravity', 'signature-bypass-strict', T('绕过缓存时严格校验签名', 'Strict validation when bypassing the cache', 'キャッシュを迂回する際の厳格な検証')),
flag('antigravity', 'connection-pool.enabled', T('启用 Antigravity 连接池', 'Enable Antigravity connection pooling', 'Antigravity 接続プールを有効化')),
{ ...text('antigravity', 'connection-pool.idle-conn-timeout', T('空闲连接超时(上限 210s)', 'Idle connection timeout (maximum 210s)', 'アイドル接続のタイムアウト(最大 210s)'), '30s'), validate: value => typeof value === 'string' && /^(?:\d+(?:\.\d+)?(?:ms|s|m))+$/.test(value) ? null : T('请输入有效时长', 'Enter a valid duration', '有効な時間を入力してください') },
number('antigravity', 'connection-pool.max-idle-conns-per-host', T('每个主机的最大空闲连接', 'Maximum idle connections per host', 'ホストごとの最大アイドル接続数'), 2),
] },
];
+48
View File
@@ -0,0 +1,48 @@
import { StructuredConfigEditor } from '../components/StructuredConfigEditor';
import type { TemplateConfigField, TemplateConfigGroup } from './templateConfig';
import { errorRulesShape, nonemptyText, stringsShape, validateStructuredValueText, type ConfigShape, type ConfigText } from './structuredConfig';
export function structuredField(path: string[], label: ConfigText, shape: ConfigShape, description?: ConfigText): TemplateConfigField {
return {
path, label, type: 'custom', description, defaultValue: shape.type === 'array' ? [] : {},
render: props => <StructuredConfigEditor {...props} shape={{ ...shape, label: shape.label ?? label }} />,
validate: value => validateStructuredValueText(shape, value),
};
}
const modelMatch: ConfigShape = { type: 'object', fields: {
name: { ...nonemptyText, initial: '*', label: { zh: '模型名称或通配符', en: 'Model name or wildcard', ja: 'モデル名 / ワイルドカード' } },
protocol: { type: 'select', optional: true, options: ['openai', 'gemini', 'claude', 'codex', 'antigravity'], label: { zh: '目标协议', en: 'Target protocol', ja: '送信先プロトコル' } },
'from-protocol': { type: 'select', optional: true, options: ['openai', 'responses', 'gemini', 'claude'], label: { zh: '来源协议', en: 'Source protocol', ja: '送信元プロトコル' } },
headers: { type: 'map', optional: true, item: { type: 'string' }, label: { zh: '请求头匹配(支持 *)', en: 'Header patterns (* supported)', ja: 'ヘッダーパターン(* 対応)' } },
match: { type: 'array', optional: true, item: { type: 'map', item: { type: 'any' } }, label: { zh: 'JSON 路径值等于', en: 'JSON path values equal', ja: 'JSON パスが一致' } },
'not-match': { type: 'array', optional: true, item: { type: 'map', item: { type: 'any' } }, label: { zh: 'JSON 路径值不等于', en: 'JSON path values do not equal', ja: 'JSON パスが不一致' } },
exist: stringsShape({ zh: '必须存在的 JSON 路径', en: 'JSON paths that must exist', ja: '存在する JSON パス' }),
'not-exist': stringsShape({ zh: '必须缺失的 JSON 路径', en: 'JSON paths that must be absent', ja: '存在しない JSON パス' }),
} };
export const payloadShape = (mode: string): ConfigShape => ({
type: 'array', item: { type: 'object', fields: {
models: { type: 'array', label: { zh: '匹配条件', en: 'Matching models', ja: '適用条件' }, item: modelMatch,
initial: [{ name: '*' }], validate: value => Array.isArray(value) && value.length ? null : { zh: '请至少添加一个模型匹配条件', en: 'At least one model pattern is required', ja: 'モデルの適用条件を 1 件以上追加してください' } },
params: mode === 'filter'
? { type: 'array', item: nonemptyText, label: { zh: '要删除的 JSON 路径', en: 'JSON paths to remove', ja: '削除する JSON パス' } }
: { type: 'map', label: { zh: 'JSON 路径与参数值', en: 'JSON paths and values', ja: 'JSON パスと値' }, item: mode.endsWith('-raw')
? { type: 'any', validate: value => { if (typeof value !== 'string') return null; try { JSON.parse(value); return null; } catch { return { zh: '每个原始字符串都必须是有效 JSON(文本字面量需加双引号)', en: 'Each raw string must be valid JSON (quote string literals)', ja: '各 raw 文字列には有効な JSON を指定してください(文字列リテラルは引用符で囲みます)' }; } } }
: { type: 'any' } },
} },
});
export const payloadTemplateGroups: TemplateConfigGroup[] = [{
id: 'request-payload', title: { zh: '请求参数规则', en: 'Request payload rules', ja: 'リクエストパラメータルール' },
description: { zh: '按顺序设置默认值、覆盖或移除参数。模型名和请求头支持通配符;JSON 路径支持 gjson/sjson 语法。只修改需要调整的规则,其余规则(包括模型别名生成的规则)会保留。', en: 'Apply defaults, overrides and filters in order. Model and header patterns support wildcards; paths use gjson/sjson syntax. Existing rules, including rules generated by aliases, are retained until edited.', ja: '既定値・上書き・削除を順番に適用します。ワイルドカードと gjson/sjson パスに対応。別名から生成された既存ルールも維持します。' },
fields: [
['default', { zh: '补充缺失参数', en: 'Default values', ja: '既定値' }],
['default-raw', { zh: '补充原始 JSON', en: 'Default raw JSON', ja: '既定の raw JSON' }],
['override', { zh: '覆盖参数', en: 'Override values', ja: '上書き' }],
['override-raw', { zh: '覆盖原始 JSON', en: 'Override raw JSON', ja: 'raw JSON の上書き' }],
['filter', { zh: '移除参数', en: 'Remove parameters', ja: 'パラメータの削除' }],
].map(([mode, label]) => structuredField(['requests', 'payload', mode as string], label as ConfigText, payloadShape(mode as string))),
}];
export { errorRulesShape };
+55 -1
View File
@@ -54,12 +54,66 @@ export function validateProviderGroupKeys(keys: ProviderKeyDraft[]): string | nu
const valueField = value[field];
if (valueField == null) continue;
if (typeof valueField !== 'number' || !Number.isSafeInteger(valueField)) return field;
if (field === 'weight' && (valueField < 0 || valueField > 1_000_000)) return field;
if (field === 'weight' && valueField > 1_000_000) return field;
}
}
return null;
}
// Validate only known editable fields. Unknown provider extensions survive edits.
export function validateProviderTemplateRecord(value: Record<string, unknown>, path = ''): string | null {
const at = (field: string) => path ? `${path}.${field}` : field;
for (const field of ['priority', 'weight', 'request-retry']) {
const item = value[field];
if (item == null) continue;
if (!Number.isSafeInteger(item) || (field === 'weight' && Number(item) > 1_000_000)) return at(field);
}
const rules = value['request-scoped-errors'];
if (rules != null) {
if (!Array.isArray(rules)) return at('request-scoped-errors');
for (const [index, rule] of rules.entries()) {
const rulePath = at(`request-scoped-errors[${index + 1}]`);
if (!isRecord(rule) || !Number.isSafeInteger(rule.status) || Number(rule.status) < 100 || Number(rule.status) > 599) return `${rulePath}.status`;
if (!['stop', 'stop-and-cooldown', 'continue', 'continue-and-cooldown'].includes(String(rule.action))) return `${rulePath}.action`;
for (const field of ['match', 'match-regexr']) {
if (rule[field] != null && (!Array.isArray(rule[field]) || rule[field].some((pattern: unknown) => typeof pattern !== 'string'))) return `${rulePath}.${field}`;
}
}
}
if (Array.isArray(value.models)) {
for (const [index, model] of value.models.entries()) {
const modelPath = at(`models[${index + 1}]`);
if (!isRecord(model) || !readString(model, 'name').trim()) return `${modelPath}.name`;
if (model['max-context-length'] != null && (!Number.isSafeInteger(model['max-context-length']) || Number(model['max-context-length']) < 0)) return `${modelPath}.max-context-length`;
for (const field of ['input-modalities', 'output-modalities']) {
if (model[field] != null && (!Array.isArray(model[field]) || model[field].some((item: unknown) => typeof item !== 'string' || !item.trim()))) return `${modelPath}.${field}`;
}
if (model.thinking != null) {
if (!isRecord(model.thinking)) return `${modelPath}.thinking`;
const thinking = model.thinking;
for (const field of ['min', 'max']) if (thinking[field] != null && (!Number.isSafeInteger(thinking[field]) || Number(thinking[field]) < 0)) return `${modelPath}.thinking.${field}`;
if (typeof thinking.min === 'number' && typeof thinking.max === 'number' && thinking.min > thinking.max) return `${modelPath}.thinking.max`;
if (thinking.levels != null && (!Array.isArray(thinking.levels) || thinking.levels.some((item: unknown) => typeof item !== 'string' || !item.trim()))) return `${modelPath}.thinking.levels`;
}
}
}
if (Array.isArray(value.keys)) for (const [index, key] of value.keys.entries()) {
if (!isRecord(key)) return at(`keys[${index + 1}]`);
const error = validateProviderTemplateRecord(key, at(`keys[${index + 1}]`));
if (error) return error;
}
return null;
}
export function appendNativeProviderGroup(groups: Record<string, unknown>[], group: Record<string, unknown>): Record<string, unknown>[] {
const base = readString(group, 'name').trim() || 'provider';
let name = base;
let suffix = 2;
const names = new Set(groups.map((item) => readString(item, 'name')));
while (names.has(name)) name = `${base} (${suffix++})`;
return [...groups.map(cleanProviderGroup), cleanProviderGroup({ ...group, name })];
}
export function serializeProviderKey(draft: ProviderKeyDraft): Record<string, unknown> {
const value = cleanProviderGroup(draft.value);
for (const [field, text] of Object.entries(draft.text ?? {})) {
+18 -13
View File
@@ -1,5 +1,5 @@
import { invoke } from '@tauri-apps/api/core';
import { normalizeBaseUrl, type ModelOption, type ModelProvider } from './modelService';
import { defaultProviderBaseUrl, providerDefaultHeaders, normalizeBaseUrl, type ModelOption, type ModelProvider } from './modelService';
export const PROVIDER_HEALTH_TIMEOUT_MS = 15_000;
export const PROVIDER_HEALTH_CONCURRENCY = 4;
@@ -8,7 +8,7 @@ export type ProviderHealthProbe = {
url: string;
header: Record<string, string>;
data: string;
protocol: 'openai-chat' | 'openai-responses' | 'claude' | 'gemini';
protocol: 'openai-chat' | 'openai-responses' | 'claude' | 'gemini' | 'interactions';
model: string;
source: string;
authIndex: string;
@@ -37,12 +37,7 @@ export type ProviderHealthCheckOptions = {
timeoutMs?: number;
};
const defaultBaseUrl = (provider: ModelProvider) => {
if (provider === 'claude') return 'https://api.anthropic.com';
if (provider === 'gemini') return 'https://generativelanguage.googleapis.com';
if (provider === 'deepseek') return 'https://api.deepseek.com';
return '';
};
const defaultBaseUrl = defaultProviderBaseUrl;
const endpointRoot = (provider: ModelProvider, baseUrl: string) => {
const normalized = normalizeBaseUrl(baseUrl.trim() || defaultBaseUrl(provider));
@@ -106,7 +101,7 @@ export function buildProviderHealthProbe(
const root = endpointRoot(provider, baseUrl);
const headers = { ...customHeaders };
const key = apiKey.trim();
const normalizedModel = provider === 'gemini'
const normalizedModel = ['gemini', 'interactions', 'vertex'].includes(provider)
? model.trim().replace(/^models\//i, '')
: model.trim();
const metadata = {
@@ -115,13 +110,22 @@ export function buildProviderHealthProbe(
authIndex: authIndex.trim(),
};
setHeaderIfMissing(headers, 'Content-Type', 'application/json');
for (const [name, value] of Object.entries(providerDefaultHeaders(provider))) setHeaderIfMissing(headers, name, value);
if (provider === 'gemini') {
if (provider === 'interactions') {
if (key) setHeaderIfMissing(headers, 'x-goog-api-key', key);
else if (authIndex) setHeaderIfMissing(headers, 'x-goog-api-key', '$TOKEN$');
return { ...metadata, url: `${root}/v1beta/interactions`, header: headers, protocol: 'interactions',
data: JSON.stringify({ model: normalizedModel, input: 'hi', stream: true }) };
}
if (provider === 'gemini' || provider === 'vertex') {
if (key) setHeaderIfMissing(headers, 'x-goog-api-key', key);
else if (authIndex) setHeaderIfMissing(headers, 'x-goog-api-key', '$TOKEN$');
return {
...metadata,
url: `${root}/v1beta/models/${encodeURIComponent(normalizedModel)}:generateContent?alt=sse`,
url: provider === 'vertex' ? `${root}/v1/publishers/google/models/${encodeURIComponent(normalizedModel)}:streamGenerateContent?alt=sse`
: `${root}/v1beta/models/${encodeURIComponent(normalizedModel)}:generateContent?alt=sse`,
header: headers,
protocol: 'gemini',
data: JSON.stringify({
@@ -154,10 +158,10 @@ export function buildProviderHealthProbe(
if (key) setHeaderIfMissing(headers, 'Authorization', `Bearer ${key}`);
else if (authIndex) setHeaderIfMissing(headers, 'Authorization', 'Bearer $TOKEN$');
if (provider === 'codex' || provider === 'deepseek') {
if (provider === 'codex' || provider === 'deepseek' || provider === 'xai' || provider === 'meta') {
return {
...metadata,
url: provider === 'deepseek'
url: provider === 'deepseek' || provider === 'xai' || provider === 'meta'
? `${normalizeBaseUrl(baseUrl.trim() || defaultBaseUrl(provider))}/responses`
: `${root}/v1/responses`,
header: headers,
@@ -220,6 +224,7 @@ export async function checkProviderHealthProbe(
responseLatencyMs: number;
}>('provider_health_probe', {
request: {
...(['interactions', 'vertex', 'xai', 'meta'].includes(provider) ? { sourceProvider: provider } : {}),
protocol: probe.protocol,
timeoutMs,
data: probe.data,
+91
View File
@@ -0,0 +1,91 @@
export type ConfigText = string | { zh: string; en: string; ja?: string };
export type ConfigShape = {
type: 'string' | 'number' | 'boolean' | 'select' | 'object' | 'array' | 'map' | 'any';
label?: ConfigText;
hint?: ConfigText;
optional?: boolean;
secret?: boolean;
min?: number;
max?: number;
integer?: boolean;
options?: readonly (string | boolean | number)[];
fields?: Record<string, ConfigShape>;
item?: ConfigShape;
initial?: unknown;
validate?: (value: unknown) => ConfigText | null;
};
export const configRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
export const defaultStructuredValue = (shape: ConfigShape): unknown => {
if (shape.initial !== undefined) return structuredClone(shape.initial);
switch (shape.type) {
case 'array': return [];
case 'object': return Object.fromEntries(Object.entries(shape.fields ?? {})
.filter(([, field]) => !field.optional).map(([key, field]) => [key, defaultStructuredValue(field)]));
case 'map': return {};
case 'boolean': return false;
case 'number': return shape.min ?? 0;
case 'select': return shape.options?.[0] ?? '';
default: return '';
}
};
type ValidationLanguage = 'zh' | 'en' | 'ja';
const validationText = (text: ConfigText, language: ValidationLanguage) => typeof text === 'string' ? text : text[language] ?? text.en;
export const validateStructuredValue = (shape: ConfigShape, value: unknown, path = '', language: ValidationLanguage = 'en'): string | null => {
if (shape.optional && value == null) return null;
const error = (detail: ConfigText) => `${path || validationText({ zh: '值', en: 'Value', ja: '値' }, language)}: ${validationText(detail, language)}`;
if (shape.type === 'string' && typeof value !== 'string') return error({ zh: '请输入文本', en: 'expected text', ja: 'テキストを入力してください' });
if (shape.type === 'boolean' && typeof value !== 'boolean') return error({ zh: '请选择启用或禁用', en: 'expected true or false', ja: '有効または無効を選択してください' });
if (shape.type === 'number' && (typeof value !== 'number' || (shape.integer === false ? !Number.isFinite(value) : !Number.isSafeInteger(value))
|| (shape.min !== undefined && value < shape.min) || (shape.max !== undefined && value > shape.max))) {
const bounds = `${shape.min !== undefined ? ` ≥ ${shape.min}` : ''}${shape.max !== undefined ? ` ≤ ${shape.max}` : ''}`;
return error({ zh: `请输入${shape.integer === false ? '有效数值' : '整数'}${bounds}`, en: `expected ${shape.integer === false ? 'a number' : 'an integer'}${bounds}`, ja: `${shape.integer === false ? '有効な数値' : '整数'}を入力してください${bounds}` });
}
if (shape.type === 'select' && !shape.options?.includes(value as string | boolean | number)) return error({ zh: '请选择支持的选项', en: 'select a supported value', ja: '対応する値を選択してください' });
if (shape.type === 'array') {
if (!Array.isArray(value)) return error({ zh: '应为列表', en: 'expected a list', ja: 'リストを指定してください' });
for (const [index, entry] of value.entries()) {
const invalid = validateStructuredValue(shape.item ?? { type: 'any' }, entry, `${path}[${index + 1}]`, language);
if (invalid) return invalid;
}
}
if (shape.type === 'object' || shape.type === 'map') {
if (!configRecord(value)) return error({ zh: '应为对象', en: 'expected an object', ja: 'オブジェクトを指定してください' });
if (shape.type === 'map') {
for (const [key, entry] of Object.entries(value)) {
if (!key.trim() || ['__proto__', 'prototype', 'constructor'].includes(key)) return error({ zh: '字段名称无效或为空', en: 'invalid or empty key', ja: 'キーが無効または空です' });
const invalid = validateStructuredValue(shape.item ?? { type: 'any' }, entry, `${path}.${key}`, language);
if (invalid) return invalid;
}
} else {
for (const [key, field] of Object.entries(shape.fields ?? {})) {
const invalid = validateStructuredValue(field, value[key], `${path}.${key}`, language);
if (invalid) return invalid;
}
}
}
const invalid = shape.validate?.(value);
return invalid ? validationText(invalid, language) : null;
};
export const validateStructuredValueText = (shape: ConfigShape, value: unknown, path = ''): ConfigText | null => {
const en = validateStructuredValue(shape, value, path);
return en === null ? null : { en, zh: validateStructuredValue(shape, value, path, 'zh') ?? en, ja: validateStructuredValue(shape, value, path, 'ja') ?? en };
};
export const textShape = (label?: ConfigText, optional = true): ConfigShape => ({ type: 'string', label, optional });
export const boolShape = (label?: ConfigText): ConfigShape => ({ type: 'boolean', label, optional: true });
export const stringsShape = (label?: ConfigText): ConfigShape => ({ type: 'array', label, optional: true, item: { type: 'string' } });
export const nonemptyText: ConfigShape = { type: 'string', validate: value => typeof value === 'string' && value.trim() ? null : { zh: '请输入内容', en: 'Please enter a value', ja: '値を入力してください' } };
export const errorRulesShape: ConfigShape = {
type: 'array', item: { type: 'object', fields: {
status: { type: 'number', min: 100, max: 599, initial: 400, label: { zh: 'HTTP 状态码', en: 'HTTP status', ja: 'HTTP ステータス' } },
match: stringsShape({ zh: '包含的文本', en: 'Text patterns', ja: 'テキストパターン' }),
'match-regexr': stringsShape({ zh: '正则表达式', en: 'Regular expressions', ja: '正規表現' }),
action: { type: 'select', options: ['stop', 'stop-and-cooldown', 'continue', 'continue-and-cooldown'], label: { zh: '处理方式', en: 'Action', ja: '処理' } },
} },
};
+102
View File
@@ -0,0 +1,102 @@
import type { ReactNode } from 'react';
export type TemplateText = string | { zh: string; en: string; ja?: string };
export type TemplateConfigField = {
path: readonly string[];
type: 'boolean' | 'string' | 'number' | 'select' | 'string-list' | 'json' | 'custom';
label: TemplateText;
description?: TemplateText;
defaultValue?: unknown;
options?: readonly { value: string | number | boolean; label: TemplateText }[];
min?: number;
max?: number;
step?: number;
placeholder?: string;
sensitive?: boolean;
directory?: boolean;
rows?: number;
restart?: boolean;
render?: (props: { value: unknown; onChange: (value: unknown) => void; disabled: boolean; id: string }) => ReactNode;
validate?: (value: unknown) => TemplateText | null;
};
export type TemplateConfigGroup = {
id: string;
title: TemplateText;
description?: TemplateText;
fields: readonly TemplateConfigField[];
validate?: (config: Record<string, unknown>) => TemplateText | null;
};
export type TemplateConfigChange = {
path: string[];
value: unknown;
remove: boolean;
expected: unknown;
expectedExists: boolean;
};
export type TemplateConfigSaveResult = { config: Record<string, unknown>; restartRequired: boolean };
export type TemplateConfigDraft = Omit<TemplateConfigChange, 'path'>;
export const templateFieldKey = (path: readonly string[]) => JSON.stringify(path);
export const sameTemplateValue = (left: unknown, right: unknown): boolean => {
if (Object.is(left, right)) return true;
if (Array.isArray(left) && Array.isArray(right)) return left.length === right.length && left.every((item, i) => sameTemplateValue(item, right[i]));
if (left && right && typeof left === 'object' && typeof right === 'object' && !Array.isArray(left) && !Array.isArray(right)) {
const keys = Object.keys(left);
return keys.length === Object.keys(right).length && keys.every((key) => Object.prototype.hasOwnProperty.call(right, key) && sameTemplateValue((left as Record<string, unknown>)[key], (right as Record<string, unknown>)[key]));
}
return false;
};
export function readTemplatePath(config: unknown, path: readonly string[]): { exists: boolean; value: unknown } {
let value = config;
for (const segment of path) {
if (!value || typeof value !== 'object' || Array.isArray(value) || !Object.prototype.hasOwnProperty.call(value, segment)) return { exists: false, value: null };
value = (value as Record<string, unknown>)[segment];
}
return { exists: true, value };
}
export function applyTemplateChanges(config: Record<string, unknown>, changes: readonly TemplateConfigChange[]): Record<string, unknown> {
const next = structuredClone(config);
for (const change of changes) {
if (!change.path.length || change.path.some((key) => ['__proto__', 'prototype', 'constructor'].includes(key))) throw new Error('Invalid configuration path');
let cursor = next;
let skip = false;
for (const segment of change.path.slice(0, -1)) {
const child = cursor[segment];
if (!child || typeof child !== 'object' || Array.isArray(child)) {
if (change.remove) { skip = true; break; }
cursor[segment] = {};
}
cursor = cursor[segment] as Record<string, unknown>;
}
if (skip) continue;
const key = change.path[change.path.length - 1];
if (change.remove) delete cursor[key];
else cursor[key] = structuredClone(change.value);
}
return next;
}
export function draftTemplateField(field: TemplateConfigField, config: Record<string, unknown>, previous: TemplateConfigDraft | undefined, value: unknown, remove = false): TemplateConfigDraft | undefined {
const original = previous ? { exists: previous.expectedExists, value: previous.expected } : readTemplatePath(config, field.path);
if (remove ? !original.exists : original.exists && sameTemplateValue(original.value, value)) return undefined;
return { value, remove, expectedExists: original.exists, expected: original.value ?? null };
}
export function templateFieldValidation(field: TemplateConfigField, value: unknown): TemplateText | null {
if (field.type === 'number' && (typeof value !== 'number' || !Number.isFinite(value) || ((field.step ?? 1) === 1 && !Number.isInteger(value)) || (field.min !== undefined && value < field.min) || (field.max !== undefined && value > field.max))) {
return { zh: `请输入有效数值${field.min !== undefined ? `,最小 ${field.min}` : ''}${field.max !== undefined ? `,最大 ${field.max}` : ''}`, en: `Enter a valid number${field.min !== undefined ? `, minimum ${field.min}` : ''}${field.max !== undefined ? `, maximum ${field.max}` : ''}`, ja: `有効な数値を入力してください${field.min !== undefined ? `(最小 ${field.min})` : ''}${field.max !== undefined ? `(最大 ${field.max})` : ''}` };
}
if (field.type === 'select' && !field.options?.some((option) => sameTemplateValue(option.value, value))) return { zh: '请选择有效选项', en: 'Select a valid option', ja: '有効な項目を選択してください' };
if (field.type === 'json' && typeof value === 'string') {
try { JSON.parse(value); } catch { return { zh: '请输入有效 JSON', en: 'Enter valid JSON', ja: '有効な JSON を入力してください' }; }
}
return field.validate?.(value) ?? null;
}
export function templateFieldSaveValue(field: TemplateConfigField, value: unknown): unknown {
if (field.type === 'string-list') return Array.isArray(value) ? value.map(String).map((item) => item.trim()).filter(Boolean) : [];
if (field.type === 'json' && typeof value === 'string') return JSON.parse(value);
return value;
}
+17
View File
@@ -0,0 +1,17 @@
.provider-template-fields, .provider-template-rules { display: grid; min-width: 0; gap: 12px; }
.provider-template-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(210px, 100%), 1fr)); gap: 12px; min-width: 0; }
.provider-template-field { display: grid; align-content: start; min-width: 0; gap: 6px; }
.provider-template-field small { color: var(--text-secondary); font-size: 11px; line-height: 1.6; overflow-wrap: anywhere; }
.provider-template-field :is(input, textarea, select), .provider-template-rule :is(input, select) { min-width: 0; max-width: 100%; width: 100%; box-sizing: border-box; }
.provider-template-section { min-width: 0; padding: 10px 12px; border: 1px solid var(--theme-e8e4dc); border-radius: 8px; }
.provider-template-section summary { cursor: pointer; font-size: 12px; overflow-wrap: anywhere; }
.provider-template-section[open] > :not(summary) { margin-top: 12px; }
.api-provider-dialog .provider-template-toggle { display: flex; align-items: center; gap: 8px; min-width: 0; }
.api-provider-dialog .provider-template-toggle input { width: 16px; height: 16px; flex-shrink: 0; }
.api-provider-dialog .provider-template-toggle span { overflow-wrap: anywhere; }
.provider-template-rule { margin: 0; padding: 10px; border: 1px solid var(--theme-e8e4dc); border-radius: 8px; min-width: 0; }
.provider-template-rule legend { font-size: 11px; padding: 0 4px; }
.provider-template-rule > button { margin-top: 10px; }
.provider-model-config { display: grid; gap: 6px; min-width: 0; }
.provider-template-section select, .provider-template-field select { min-height: 36px; border: 1px solid var(--theme-e8e4dc); border-radius: 7px; background: var(--bg-primary); color: var(--text-primary); padding: 5px 8px; font: inherit; }
.api-provider-dialog .provider-template-fields { grid-column: 1 / -1; }
+31
View File
@@ -0,0 +1,31 @@
.template-config-section { display: grid; gap: 20px; min-width: 0; margin-top: 20px; }
.template-config-section[hidden], .template-config-card[hidden] { display: none; }
.template-config-card { min-width: 0; padding: 22px; border: 1px solid var(--border-color, #dce5ee); border-radius: 14px; background: var(--bg-card, #fff); }
.template-config-heading h3 { display: flex; align-items: center; gap: 9px; margin: 0; font-size: 16px; }
.template-config-heading p, .template-config-field small { color: var(--text-secondary, #586779); line-height: 1.65; overflow-wrap: anywhere; }
.template-config-heading p { margin: 9px 0 0; font-size: 13px; }
.template-config-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 22px; margin-top: 20px; }
.template-config-field { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; min-width: 0; }
.template-config-field-wide { grid-column: 1 / -1; }
.template-config-label { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 10px; font-weight: 600; font-size: 13px; width: 100%; min-width: 0; }
.template-config-label label { overflow-wrap: anywhere; }
.template-config-origin { font-size: 10px; font-weight: 400; color: var(--text-secondary, #64748b); border: 1px solid var(--border-color, #dce5ee); padding: 2px 6px; border-radius: 6px; }
.template-config-origin-dirty { color: var(--primary-color, #087e79); border-color: currentColor; }
.template-config-control { min-width: 0; width: 100%; }
.template-config-directory { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; min-width: 0; }
.template-config-directory > input { flex: 1 1 180px; min-width: 0; width: auto; }
.template-config-directory > button { flex: 0 1 auto; white-space: normal; }
.template-config-custom { width: 100%; min-width: 0; border: 1px solid var(--border-color); border-radius: 9px; padding: 10px 12px; box-sizing: border-box; }
.template-config-custom > summary { cursor: pointer; color: var(--primary-color); font-size: 12px; }
.template-config-custom[open] > summary { margin-bottom: 14px; }
.template-config-control > input, .template-config-control > select, .template-config-control > textarea { box-sizing: border-box; width: 100%; max-width: 100%; min-width: 0; }
.template-config-textarea { resize: vertical; line-height: 1.6; padding: 10px 12px; font-family: inherit; }
.template-config-control > textarea.template-config-textarea { height: auto; min-height: 86px; padding: 10px 12px; }
.template-config-reset { display: inline-flex; align-items: center; gap: 5px; padding: 0; border: 0; background: transparent; color: var(--primary-color, #087e79); cursor: pointer; font-size: 11px; }
.template-config-reset:disabled { opacity: .5; cursor: default; }
.template-config-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border-color, #dce5ee); }
.template-config-actions > span { font-size: 12px; color: var(--text-secondary, #64748b); }
.template-config-actions > div { display: flex; flex-wrap: wrap; gap: 8px; margin-left: auto; }
.template-config-notice { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 14px; border: 1px solid var(--border-color, #dce5ee); border-radius: 10px; overflow-wrap: anywhere; font-size: 13px; }
.template-config-error, .template-config-field small.template-config-error { color: var(--danger-color, #c0392b); overflow-wrap: anywhere; }
@media (max-width: 680px) { .template-config-card { padding: 16px; } .template-config-actions > div { width: 100%; } .template-config-actions button { flex: 1 1 auto; white-space: normal; } }
+15 -1
View File
@@ -6,7 +6,7 @@ describe('credential settings', () => {
const draft = authFileSettingsFromPayload(JSON.stringify({ prefix: 'team', 'proxy-url': 'socks5://localhost:1080', priority: -4, weight: 6,
'disable-cooling': false, websocket: true, 'excluded-models': [' GPT-* ', 'gpt-*'], headers: { 'X-Team': 'test' }, note: 'note', access_token: 'secret' }));
expect(draft).toEqual({ prefix: 'team', proxy_url: 'socks5://localhost:1080', priority: '-4', weight: '6', disable_cooling: 'false',
websockets: 'true', excluded_models: 'gpt-*', headers: '{\n "X-Team": "test"\n}', note: 'note' });
websockets: 'true', excluded_models: 'gpt-*', headers: '{\n "X-Team": "test"\n}', note: 'note', advanced: {} });
expect(JSON.stringify(draft)).not.toContain('secret');
});
@@ -85,4 +85,18 @@ describe('credential settings', () => {
get: async () => ({}), patch: async () => { throw new Error('save failed'); },
})).rejects.toThrow('save failed');
});
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 }] });
expect(buildAuthFileSettingsPatch(original, { ...original, advanced: { ...original.advanced, 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: [] });
expect(JSON.stringify(original)).not.toContain('never-copy');
for (const advanced of [{ timezone: 'Not/AZone' }, { cloak_mode: 'invalid' }, { model_aliases: [{ name: '', alias: 'x' }] }]) {
expect(() => buildAuthFileSettingsPatch(original, { ...original, advanced: { ...original.advanced, ...advanced } })).toThrow();
}
});
});
+89
View File
@@ -96,3 +96,92 @@ describe('browser mock runtime', () => {
expect(await runtime.invoke('plugin:app|version')).toBe('0.2.97-mock');
});
});
describe('browser mock native provider groups', () => {
const providers = ['gemini', 'interactions', 'vertex', 'codex', 'claude', 'xai', 'meta', 'openai-compatibility'] as const;
const request = (runtime: ReturnType<typeof createBrowserMockRuntime>, method: string, path: string, body?: unknown) =>
runtime.invoke('management_request', { request: { method, path, body } });
for (const provider of providers) {
test(`preserves native ${provider} groups, overrides, and extensions across saves`, async () => {
const runtime = createBrowserMockRuntime('running');
const path = `/config/api-keys/${provider}`;
const groups = [{
name: `${provider} production`,
'base-url': 'https://provider.example/v1',
headers: { 'X-Team': 'shared' },
models: [{ name: 'model', alias: 'primary', 'future-model-option': false }],
'request-retry': 0,
'future-group-option': { enabled: false, values: [] },
keys: [
{ 'api-key': 'first', weight: 2, headers: null, models: [], 'future-key-option': 0 },
{ 'api-key': 'second', 'proxy-url': '', headers: {}, models: null },
],
}, { name: `${provider} empty standby`, keys: [], 'future-group-option': null }];
expect(await request(runtime, 'GET', path)).toBeArray();
expect(await request(runtime, 'PUT', path, groups)).toEqual({ status: 'ok', 'config-version': 8 });
expect(await request(runtime, 'GET', path)).toEqual(groups);
expect(await request(runtime, 'GET', '/config')).toMatchObject({
'config-version': 8, 'api-keys': { [provider]: groups },
});
// Reading the legacy view must not flatten the authoritative state.
const legacyPath = provider === 'openai-compatibility' ? path.slice('/config/api-keys'.length) : `/${provider}-api-key`;
await request(runtime, 'GET', legacyPath);
expect(await request(runtime, 'GET', path)).toEqual(groups);
// Both writes and reads isolate stored values from caller mutations.
const expected = structuredClone(groups);
groups[0].keys[0]['api-key'] = 'mutated request';
const readBack = await request(runtime, 'GET', path) as typeof groups;
readBack[0].keys[0]['api-key'] = 'mutated response';
expect(await request(runtime, 'GET', path)).toEqual(expected);
await request(runtime, 'PUT', path, []);
expect(await request(runtime, 'GET', path)).toEqual([]);
});
}
test('derives effective legacy key records without changing native inheritance', async () => {
const runtime = createBrowserMockRuntime('running');
const groups = [{
name: 'preserve this group name',
'base-url': 'https://provider.example',
priority: 5,
headers: { 'X-Team': 'shared' },
models: [{ name: 'shared-model' }],
keys: [
{ 'api-key': 'first', priority: 0, headers: null, models: [] },
{ 'api-key': 'second', headers: {}, models: null },
],
}];
await request(runtime, 'PUT', '/config/api-keys/interactions', groups);
expect(await request(runtime, 'GET', '/interactions-api-key')).toEqual({
'interactions-api-key': [
{ 'api-key': 'first', 'base-url': 'https://provider.example', priority: 0, headers: { 'X-Team': 'shared' }, models: [] },
{ 'api-key': 'second', 'base-url': 'https://provider.example', priority: 5, headers: {}, models: [{ name: 'shared-model' }] },
],
});
expect(await request(runtime, 'GET', '/config/api-keys/interactions')).toEqual(groups);
});
test('adapts legacy OpenAI mutations while preserving native group metadata and keys', async () => {
const runtime = createBrowserMockRuntime('running');
const group = {
name: 'custom group', 'base-url': 'https://provider.example/v1', disabled: false,
'future-option': { enabled: false },
keys: [{ 'api-key': 'first', weight: 0, 'proxy-url': '' }, { 'api-key': 'second', weight: 3 }],
};
await request(runtime, 'PUT', '/config/api-keys/openai-compatibility', [group]);
await request(runtime, 'PATCH', '/openai-compatibility', { index: 0, value: { disabled: true } });
expect(await request(runtime, 'GET', '/config/api-keys/openai-compatibility')).toEqual([{ ...group, disabled: true }]);
const { keys, ...shared } = group;
expect(await request(runtime, 'GET', '/openai-compatibility')).toEqual({
'openai-compatibility': [{ ...shared, disabled: true, 'api-key-entries': keys }],
});
await request(runtime, 'PUT', '/openai-compatibility', [{ ...shared, 'api-key-entries': [keys[1]] }]);
expect(await request(runtime, 'GET', '/config/api-keys/openai-compatibility')).toEqual([{ ...shared, keys: [keys[1]] }]);
});
});
+58 -1
View File
@@ -1,7 +1,7 @@
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
import { clearMocks, mockIPC } from '@tauri-apps/api/mocks';
import { spawn, type ChildProcess } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join, resolve, sep } from 'node:path';
@@ -146,6 +146,27 @@ oauth: {auth-dir: ${JSON.stringify(join(work, 'auth'))}}
await managementApi.delete('/oauth-excluded-models', { query: { provider: 'claude' } });
});
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' }));
try {
const original = await loadAuthFileSettings(fileName);
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 }] };
await saveAuthFileSettings(fileName, original, { ...original, advanced });
const loaded = await loadAuthFileSettings(fileName);
expect(loaded.advanced).toEqual(advanced);
const metadata = await managementApi.get<Record<string, unknown>>('/auth-files/download', { name: fileName });
expect(metadata.access_token).toBe('isolated-template-token');
expect(metadata.custom_metadata).toEqual({ keep: true });
await saveAuthFileSettings(fileName, loaded, { ...loaded, advanced: {} });
const cleared = await loadAuthFileSettings(fileName);
expect(Object.values(cleared.advanced).every(value => value === null)).toBe(true);
} finally { await managementApi.delete('/auth-files', { query: { name: fileName } }); }
});
it('discovers models and forwards API-call requests to an isolated local upstream', async () => {
const upstream = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(request) {
if (request.headers.get('Authorization') !== 'Bearer local-key') return new Response('wrong key', { status: 401 });
@@ -170,4 +191,40 @@ oauth: {auth-dir: ${JSON.stringify(join(work, 'auth'))}}
await expect(call('POST', '/oauth/callback', undefined, { provider: 'codex', redirect_url: 'http://localhost/callback' })).rejects.toThrow('(400)');
await managementApi.delete('/oauth-session', { query: { state: 'isolated-unknown-state' } });
});
it('accepts new template controls and retains explicit false, zero and empty collections', async () => {
const values: Array<[string, unknown]> = [
['routing/strategy', 'weighted-round-robin'], ['routing/session-affinity-subagents', false],
['routing/cooldown/transient-error-cooldown-seconds', -1], ['routing/retry/max-retry-interval', -1],
['requests/streaming/keepalive-seconds', 0], ['server/trusted-proxies', []],
['client/codex/enable-apply-patch', false], ['client/codex/optimize-multi-agent-v2', true],
['multimedia/disable-image-generation', 'passthrough'],
['oauth/settings', { codex: [{ name: 'gpt-template', 'max-context-length': 524288 }] }],
['oauth/model-alias', { meta: [{ name: 'muse-template', alias: 'public-template', fork: false, 'display-name': 'Template', 'force-mapping': true }] }],
['oauth/request-scoped-errors', { claude: [{ status: 400, match: ['context'], 'match-regexr': ['^limit'], action: 'stop' }] }],
['requests/payload/default-raw', [{ models: [{ name: 'gpt-*', protocol: 'codex', 'from-protocol': 'responses', headers: { 'X-Tier': 'test-*' },
match: [{ 'metadata.client': 'codex' }], 'not-match': [{ 'metadata.mode': 'dev' }], exist: ['input'], 'not-exist': ['metadata.disable'] }],
params: { response_format: '{"type":"json_object"}', temperature: 0.5, enabled: false, schema: { type: 'object' } } }]],
['plugins/configs', { 'template-test': { enabled: false, mode: 'safe', nested: { list: [1, false, 'value'] } } }],
];
for (const [path, value] of values) {
await call('PUT', `/config/${path}`, undefined, value);
const actual = await call('GET', `/config/${path}`);
// The core's typed alias JSON omits fork:false (its default); the
// effective value is still false. Other explicit defaults stay intact.
if (path === 'oauth/model-alias') actual.meta[0].fork ??= false;
expect(actual).toEqual(value);
}
});
it('keeps TURN secrets in YAML although the JSON config response redacts them', async () => {
const relay = { enabled: false, 'max-sessions': 1, 'udp-port-min': 49152, 'udp-port-max': 49155,
'ice-servers': [{ urls: ['turn:example.invalid:3478'], username: 'isolated', credential: 'isolated-turn-secret' }] };
await call('PUT', '/config/oauth/providers/codex/live-media-relay', undefined, relay);
const json = await call('GET', '/config/oauth/providers/codex/live-media-relay');
expect(JSON.stringify(json)).not.toContain('isolated-turn-secret');
expect(await readFile(join(work, 'config.yaml'), 'utf8')).toContain('isolated-turn-secret');
await call('PUT', '/config/oauth/providers/codex/live-media-relay/max-sessions', undefined, 2);
expect(await readFile(join(work, 'config.yaml'), 'utf8')).toContain('isolated-turn-secret');
});
});
@@ -66,6 +66,51 @@ oauth: {auth-dir: ${JSON.stringify(join(work, 'auth'))}}
return flattenV8ProviderGroups(provider, await loaded.json());
};
for (const provider of ['gemini', 'interactions', 'vertex', 'codex', 'claude', 'xai', 'meta', 'openai-compatibility']) {
it(`${provider}: saves all template provider, key and model options through native v8 groups`, async () => {
const section = (provider === 'openai-compatibility' ? provider : `${provider}-api-key`) as ProviderSection;
const model: Record<string, unknown> = { name: 'isolated-model', alias: 'isolated-public' };
{
model.thinking = { levels: ['low', 'high'], min: 0, max: 32768, 'zero-allowed': false, 'dynamic-allowed': true };
model['display-name'] = 'Isolated model';
if (provider !== 'vertex') { model['max-context-length'] = 1048576; model['is-compat'] = false; }
}
model['force-mapping'] = true;
if (['codex', 'xai', 'meta'].includes(provider)) model['support-configuration-update'] = false;
if (provider === 'openai-compatibility') Object.assign(model, { image: false, 'input-modalities': ['text', 'image'], 'output-modalities': ['text'], 'use-max-completion-tokens': true });
const special = provider === 'codex' ? { 'disable-codex-cloaking': false, 'alpha-search': false, websockets: false }
: provider === 'claude' ? { 'rebuild-mid-system-message': false, 'fingerprint-profile': 'claude-code-cli', 'experimental-cch-signing': false,
cloak: { mode: 'auto', 'strict-mode': false, 'sensitive-words': [], 'cache-user-id': false } }
: provider === 'xai' ? { websockets: true } : {};
const group = { name: 'isolated-template', 'base-url': 'https://gateway.example.test/v1', priority: 0, prefix: '', 'proxy-url': 'direct',
headers: { 'X-Session': '$X-Client-Session' }, 'request-retry': 0, 'disable-cooling': false,
'request-scoped-errors': [{ status: 400, match: ['context'], 'match-regexr': ['^context'], action: 'stop' }],
...(provider === 'openai-compatibility' ? { 'support-prompt-cache-key': false } : {}),
models: [model], 'excluded-models': [],
keys: [{ 'api-key': 'isolated-template-key', weight: 0, priority: null, models: null, headers: {}, 'request-scoped-errors': [], ...special }],
};
if (provider === 'vertex') {
delete (group as Record<string, unknown>)['request-scoped-errors'];
delete (group.keys[0] as Record<string, unknown>)['request-scoped-errors'];
}
if (provider === 'openai-compatibility') {
delete (group as Record<string, unknown>)['proxy-url'];
delete (group as Record<string, unknown>)['excluded-models'];
group.keys = [{ 'api-key': 'isolated-template-key', weight: 0, 'proxy-url': 'direct' }] as never;
}
const draft = providerDraftFromRecord(section, group);
const saved = buildProviderGroupRecord(section, draft, group);
const url = `${origin}/config/api-keys/${provider}`;
const response = await fetch(url, { method: 'PUT', headers, body: JSON.stringify([saved]) });
const result = await response.text();
expect({ status: response.status, result: response.ok ? 'ok' : result }).toEqual({ status: 200, result: 'ok' });
const loaded = await (await fetch(url, { headers })).json();
expect(loaded[0].keys[0]['api-key']).toBe('isolated-template-key');
expect(loaded[0]['request-retry']).toBe(0);
expect(loaded[0].models[0].alias).toBe('isolated-public');
});
}
it('native group edits preserve multiple keys, shared models and explicit overrides in v8', async () => {
const url = `${origin}/config/api-keys/codex`;
const groups = [{ name: 'native-multi-key', 'base-url': 'https://gateway.example.test/v1',
+5 -1
View File
@@ -277,13 +277,17 @@ it('keeps remark identities separate for records that share an API key', () => {
});
describe('API 接入配置合并', () => {
it('固定使用 Codex、OpenAI、DeepSeek、Claude、Gemini 顺序且不包含 Vertex', () => {
it('保留常用分类顺序并覆盖 v8 模板全部原生提供商', () => {
expect(providerSectionOrder).toEqual([
'codex-api-key',
'openai-compatibility',
'deepseek',
'claude-api-key',
'gemini-api-key',
'interactions-api-key',
'vertex-api-key',
'xai-api-key',
'meta-api-key',
]);
});
+128
View File
@@ -0,0 +1,128 @@
import { describe, expect, it } from 'bun:test';
import { buildProviderGroupRecord, providerDraftFromRecord, type ProviderSection } from '../src/pages/ApiAccessPage';
import { appendNativeProviderGroup, validateProviderTemplateRecord, validateProviderGroupKeys, providerKeyDraft } from '../src/services/providerGroups';
import { modelEndpointCandidates, modelsFromRecord } from '../src/services/modelService';
import { buildProviderHealthProbe } from '../src/services/providerHealthCheck';
const sections: ProviderSection[] = ['gemini-api-key', 'interactions-api-key', 'vertex-api-key', 'codex-api-key', 'claude-api-key', 'xai-api-key', 'meta-api-key', 'openai-compatibility'];
describe('v8 provider template fields', () => {
for (const section of sections) it(`${section} preserves unknown data, explicit defaults and inheritance`, () => {
const group = {
name: 'production', priority: 0, headers: {}, 'request-retry': -1, 'request-scoped-errors': [],
extension: { future: false }, models: null,
keys: [{ 'api-key': 'first', weight: -2, 'request-scoped-errors': null, models: [], headers: null, 'disable-cooling': false },
{ 'api-key': 'second', weight: 0, 'proxy-url': '', extension: { opaque: 0 } }],
};
const draft = providerDraftFromRecord(section, group);
draft.name = 'renamed';
expect(buildProviderGroupRecord(section, draft, group)).toEqual({ ...group, name: 'renamed' });
});
it('edits group error rules separately from key overrides without merging lists', () => {
const group = { name: 'gateway', 'request-retry': 5,
'request-scoped-errors': [{ status: 400, match: ['old'], action: 'continue', opaque: { keep: true } }],
keys: [{ 'api-key': 'inherited', 'request-scoped-errors': null }, { 'api-key': 'cleared', 'request-scoped-errors': [] }] };
const draft = providerDraftFromRecord('codex-api-key', group);
draft.templateFields!['request-retry'] = 0;
(draft.templateFields!['request-scoped-errors'] as any[])[0].match = [];
const saved = buildProviderGroupRecord('codex-api-key', draft, group);
expect(saved['request-retry']).toBe(0);
expect(saved['request-scoped-errors']).toEqual([{ ...group['request-scoped-errors'][0], match: [] }]);
expect(saved.keys).toEqual(group.keys);
delete draft.templateFields!['request-retry'];
expect(buildProviderGroupRecord('codex-api-key', draft, group)).not.toHaveProperty('request-retry');
});
it('supports explicit model capabilities and removal without dropping future metadata or pooled aliases', () => {
const models = [
{ name: 'model-a', alias: 'pooled', 'display-name': 'A', 'max-context-length': 32768, 'is-compat': true,
thinking: { levels: ['low', 'high'], 'zero-allowed': false, vendor: 1 }, custom: { preserve: [] } },
{ name: 'model-b', alias: 'pooled', image: false, 'input-modalities': ['text', 'image'], 'output-modalities': ['text'] },
];
const group = { name: 'gateway', models, keys: [{ 'api-key': 'key' }] };
const draft = providerDraftFromRecord('openai-compatibility', group);
const first = draft.models[0];
first.config = { ...first.config, 'is-compat': false, 'max-context-length': 0, 'display-name': '' };
delete first.config.thinking;
delete first.thinking;
const second = draft.models[1];
second.config = { ...second.config, 'input-modalities': [], 'output-modalities': [], 'use-max-completion-tokens': false };
const saved = buildProviderGroupRecord('openai-compatibility', draft, group);
expect(saved.models).toEqual([
{ name: 'model-a', alias: 'pooled', 'display-name': '', 'max-context-length': 0, 'is-compat': false, custom: { preserve: [] } },
{ ...models[1], 'input-modalities': [], 'output-modalities': [], 'use-max-completion-tokens': false },
]);
});
it('retains per-key provider options when rotating the credential', () => {
for (const options of [
{ 'disable-codex-cloaking': false, 'alpha-search': true, websockets: false },
{ 'rebuild-mid-system-message': false, 'fingerprint-profile': '', 'experimental-cch-signing': false,
cloak: { mode: 'never', 'strict-mode': false, 'sensitive-words': [], 'cache-user-id': false, custom: {} } },
]) {
const group = { name: 'gateway', keys: [{ 'api-key': 'old', ...options }] };
const draft = providerDraftFromRecord('claude-api-key', group);
draft.groupKeys![0].value['api-key'] = 'new';
expect(buildProviderGroupRecord('claude-api-key', draft, group).keys).toEqual([{ 'api-key': 'new', ...options }]);
}
});
it('rejects invalid retry/error/model values and accepts negative weights that disable weighted routing', () => {
for (const value of [
{ 'request-retry': 1.5 }, { 'request-scoped-errors': [{ status: 600, action: 'stop' }] },
{ 'request-scoped-errors': [{ status: 400, action: 'retry' }] },
{ models: [{ name: 'm', 'max-context-length': -1 }] },
{ models: [{ name: 'm', thinking: { min: 500, max: 200 } }] },
{ keys: [{ models: [{ name: 'm', thinking: { levels: [2] } }] }] },
]) expect(validateProviderTemplateRecord(value)).not.toBeNull();
expect(validateProviderTemplateRecord({ 'request-retry': -1, keys: [{ weight: -1 }] })).toBeNull();
expect(validateProviderGroupKeys([providerKeyDraft({ 'api-key': 'k', weight: -1 })])).toBeNull();
});
it('EasyMode appends one native group without flattening or losing existing overrides', () => {
const existing = [{ name: 'codex', opaque: { keep: null }, models: [{ name: 'm', thinking: { levels: [] } }], keys: [
{ 'api-key': 'a', weight: 5, headers: null, models: null },
{ 'api-key': 'b', weight: 0, headers: {}, models: [], 'request-retry': 0 },
] }, { name: 'codex (2)', keys: [] }];
const snapshot = structuredClone(existing);
const next = appendNativeProviderGroup(existing, { name: 'codex', keys: [{ 'api-key': 'c' }] });
expect(next.slice(0, 2)).toEqual(snapshot);
expect(next[2]).toEqual({ name: 'codex (3)', keys: [{ 'api-key': 'c' }] });
expect(existing).toEqual(snapshot);
});
it('retains full model metadata only when requested for the configuration editor', () => {
const record = { name: 'm', 'support-configuration-update': false, thinking: { zero_allowed: false, levels: [] }, unknown: [] };
expect(modelsFromRecord([record], true)[0].config).toEqual({ ...record, thinking: { 'zero-allowed': false, levels: [] } });
expect(modelsFromRecord([record])[0]).not.toHaveProperty('config');
});
});
describe('new native provider connections', () => {
it('uses native model discovery defaults', () => {
expect(modelEndpointCandidates('interactions', '')).toEqual(['https://generativelanguage.googleapis.com/v1beta/models']);
expect(modelEndpointCandidates('vertex', '')[0]).toBe('https://aiplatform.googleapis.com/v1/publishers/google/models');
expect(modelEndpointCandidates('xai', '')).toEqual(['https://api.x.ai/v1/models']);
expect(modelEndpointCandidates('meta', '')).toEqual(['https://api.meta.ai/v1/models']);
});
it('probes Interactions and Vertex with Google keys and their native endpoints', () => {
const interactions = buildProviderHealthProbe('interactions', '', 'models/gemini-3-pro-preview', 'google-key');
expect(interactions.url).toBe('https://generativelanguage.googleapis.com/v1beta/interactions');
expect(interactions.protocol).toBe('interactions');
expect(interactions.header['x-goog-api-key']).toBe('google-key');
expect(JSON.parse(interactions.data)).toEqual({ model: 'gemini-3-pro-preview', input: 'hi', stream: true });
const vertex = buildProviderHealthProbe('vertex', '', 'gemini-3-pro-preview', 'vertex-key');
expect(vertex.url).toBe('https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3-pro-preview:streamGenerateContent?alt=sse');
expect(vertex.header['x-goog-api-key']).toBe('vertex-key');
});
it('uses native Responses defaults for xAI and Meta', () => {
for (const [provider, host] of [['xai', 'api.x.ai'], ['meta', 'api.meta.ai']] as const) {
const probe = buildProviderHealthProbe(provider, '', 'native-model', 'key');
expect(probe.url).toBe(`https://${host}/v1/responses`);
expect(probe.protocol).toBe('openai-responses');
expect(probe.header.Authorization).toBe('Bearer key');
expect(buildProviderHealthProbe(provider, 'https://gateway.test/api', 'native-model', 'key').url).toBe('https://gateway.test/api/responses');
expect(modelEndpointCandidates(provider, 'https://gateway.test/api')).toEqual(['https://gateway.test/api/models']);
}
});
});
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'bun:test';
import { defaultStructuredValue, validateStructuredValue, validateStructuredValueText, errorRulesShape, type ConfigShape } from '../src/services/structuredConfig';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { StructuredConfigEditor } from '../src/components/StructuredConfigEditor';
import { I18nProvider } from '../src/i18n';
import { credentialAdvancedShape } from '../src/services/credentialAdvancedSettings';
import { payloadShape } from '../src/services/payloadTemplateFields';
import { oauthTemplateGroups } from '../src/services/oauthTemplateFields';
import { pluginStoreAuthShape } from '../src/services/extensionTemplateFields';
describe('structured configuration forms', () => {
it('allows decimals for arbitrary JSON values while schema numbers retain integer controls', () => {
const render = (shape: ConfigShape) => renderToStaticMarkup(createElement(I18nProvider, null,
createElement(StructuredConfigEditor, { shape, value: 0.5, onChange: () => {}, id: 'value' })));
const arbitrary = render({ type: 'any', label: 'Temperature' });
expect(arbitrary).toContain('step="any"');
expect(arbitrary).toContain('aria-label="Temperature type"');
expect(arbitrary).toContain('value="number" selected="">Number');
expect(render({ type: 'number' })).toContain('step="1"');
expect(validateStructuredValue({ type: 'number' }, 0.5)).toContain('integer');
expect(validateStructuredValue({ type: 'any' }, 0.5)).toBeNull();
});
it('localizes nested and custom validation without changing the default English API', () => {
const integer: ConfigShape = { type: 'object', fields: { count: { type: 'number', min: 0 } } };
expect(validateStructuredValue(integer, { count: 1.5 })).toBe('.count: expected an integer ≥ 0');
expect(validateStructuredValueText(integer, { count: 1.5 })).toEqual({
en: '.count: expected an integer ≥ 0', zh: '.count: 请输入整数 ≥ 0', ja: '.count: 整数を入力してください ≥ 0',
});
expect(validateStructuredValueText(credentialAdvancedShape, { timezone: 'Not/AZone' })).toEqual({
en: 'Enter an IANA timezone such as Asia/Shanghai', zh: '请输入 IANA 时区,例如 Asia/Shanghai', ja: 'Asia/Shanghai などの IANA タイムゾーンを入力してください',
});
expect(validateStructuredValueText(credentialAdvancedShape, { timezone: 'Asia/Tokyo' })).toBeNull();
});
it('does not turn optional settings into explicit defaults when a row is added', () => {
expect(defaultStructuredValue(credentialAdvancedShape)).toEqual({});
expect(defaultStructuredValue(errorRulesShape.item!)).toEqual({ status: 400, action: 'stop' });
});
it('keeps explicit false, zero and empty collections distinct from missing values', () => {
const shape: ConfigShape = { type: 'object', fields: { enabled: { type: 'boolean', optional: true }, cap: { type: 'number', optional: true, min: 0 },
list: { type: 'array', optional: true, item: { type: 'string' } } } };
expect(validateStructuredValue(shape, { enabled: false, cap: 0, list: [], future_metadata: true })).toBeNull();
expect(validateStructuredValue(shape, {})).toBeNull();
expect(validateStructuredValue(shape, { cap: -1 })).toContain('integer');
expect(validateStructuredValue(shape, { cap: 1.5 })).toContain('integer');
});
it('validates nested rule actions and model identifiers while preserving unknown metadata', () => {
expect(validateStructuredValue(errorRulesShape, [{ status: 429, action: 'continue-and-cooldown', match: [] }])).toBeNull();
expect(validateStructuredValue(errorRulesShape, [{ status: 99, action: 'stop' }])).not.toBeNull();
expect(validateStructuredValue(errorRulesShape, [{ status: 400, action: 'retry-forever' }])).not.toBeNull();
expect(validateStructuredValue(credentialAdvancedShape, { model_aliases: [{ name: 'upstream', alias: 'public', fork: false, future_metadata: { retain: true } }] })).toBeNull();
expect(validateStructuredValue(credentialAdvancedShape, { model_aliases: [{ name: 'upstream', alias: '' }] })).not.toBeNull();
});
it('accepts typed raw JSON values and validates only strings as raw JSON syntax', () => {
const shape = payloadShape('override-raw');
expect(validateStructuredValue(shape, [{ models: [{ name: 'gpt-*' }], params: { temperature: 0.5, enabled: false,
schema: { type: 'object' }, list: [1, 'value'], literal: '"string"', number: '0.5' } }])).toBeNull();
expect(validateStructuredValue(shape, [{ models: [{ name: 'gpt-*' }], params: { invalid: 'unquoted-string' } }])).toContain('valid JSON');
});
it('accepts native plugin authentication defaults and all supported scopes', () => {
for (const rule of [
{ match: 'https://plugins.example.com/' },
{ match: 'http://plugins.example.com/', type: 'none', 'allow-insecure': true },
{ match: 'https://plugins.example.com/', type: 'bearer', 'token-env': 'PLUGIN_TOKEN' },
{ match: 'https://plugins.example.com/', type: 'github-token', 'token-env': 'GITHUB_TOKEN', 'apply-to': [] },
{ match: 'https://plugins.example.com/', type: 'header', 'header-name': 'X-Token', 'header-value-env': 'PLUGIN_TOKEN', 'apply-to': ['metadata', 'artifact', 'registry'] },
]) expect(validateStructuredValue(pluginStoreAuthShape, [rule])).toBeNull();
expect(validateStructuredValue(pluginStoreAuthShape, [{ match: 'https://plugins.example.com/', type: 'bearer' }])).not.toBeNull();
expect(validateStructuredValue(pluginStoreAuthShape, [{ match: 'https://plugins.example.com/', 'apply-to': ['unknown'] }])).not.toBeNull();
});
it('edits OAuth model capabilities without forcing an optional context override', () => {
const field = oauthTemplateGroups.find(group => group.id === 'oauth-models')!.fields.find(field => field.path.at(-1) === 'settings')!;
for (const setting of [
{ name: 'gpt-5.4' },
{ name: 'gpt-5.4', alias: 'public-model' },
{ name: 'gpt-5.4', 'max-context-length': 0 },
{ name: 'gpt-5.4', alias: 'public-model', 'max-context-length': 200000, future: true },
]) expect(field.validate!({ codex: [setting] })).toBeNull();
expect(field.validate!({ codex: [{ name: 'gpt-5.4', 'max-context-length': -1 }] })).not.toBeNull();
});
});
+137
View File
@@ -0,0 +1,137 @@
import { describe, expect, test } from 'bun:test';
import {
applyTemplateChanges, draftTemplateField, readTemplatePath, sameTemplateValue,
templateFieldSaveValue, templateFieldValidation, type TemplateConfigField,
} from '../src/services/templateConfig';
import { createBrowserMockRuntime } from '../src/mocks/browserMockRuntime';
import { generalTemplateGroups, networkTemplateGroups, requestTemplateGroups, routingTemplateGroups } from '../src/services/generalTemplateFields';
import { templateMessages } from '../src/i18n/templateConfig';
import { oauthTemplateGroups } from '../src/services/oauthTemplateFields';
const field: TemplateConfigField = { path: ['oauth', 'providers', 'codex', 'response-steering'], type: 'boolean', label: 'Steering', defaultValue: false };
const change = (path: string[], value: unknown, expected: unknown = null, expectedExists = false, remove = false) => ({ path, value, expected, expectedExists, remove });
describe('template configuration drafts', () => {
test('all general settings labels and help messages include Japanese', () => {
const groups = [...generalTemplateGroups, ...networkTemplateGroups, ...requestTemplateGroups, ...routingTemplateGroups];
const messages = [...Object.values(templateMessages), ...groups.flatMap((group) => [group.title, group.description, ...group.fields.flatMap((item) => [item.label, item.description, ...item.options?.map((option) => option.label) ?? []])])].filter((value) => value !== undefined);
for (const message of messages) {
expect(typeof message).toBe('object');
expect((message as { ja: string }).ja.length).toBeGreaterThan(0);
}
});
test('preserves absent versus explicit false, zero and empty collections', () => {
for (const value of [false, 0, '', [], {}]) {
const draft = draftTemplateField(field, {}, undefined, value);
expect(draft).toMatchObject({ value, remove: false, expectedExists: false });
const saved = applyTemplateChanges({}, [{ ...draft!, path: [...field.path] }]);
expect(readTemplatePath(saved, field.path)).toEqual({ exists: true, value });
}
});
test('retains original expected values across external refresh and subsequent edits', () => {
const initial = applyTemplateChanges({}, [change([...field.path], false)]);
const draft = draftTemplateField(field, initial, undefined, true);
const refreshed = applyTemplateChanges(initial, [change([...field.path], true)]);
expect(draftTemplateField(field, refreshed, draft, true)).toEqual(draft);
expect(draftTemplateField(field, refreshed, draft, false)).toBeUndefined();
});
test('reset removes only selected leaf and preserves unknown neighbors', () => {
const source = { requests: { payload: { rules: [1] }, enabled: false }, unknown: { nested: ['preserve'] } };
const result = applyTemplateChanges(source, [change(['requests', 'enabled'], null, false, true, true)]);
expect(result).toEqual({ requests: { payload: { rules: [1] } }, unknown: { nested: ['preserve'] } });
expect(source.requests.enabled).toBe(false);
expect(readTemplatePath(result, ['requests', 'enabled']).exists).toBe(false);
});
test('uses structural equality without depending on map order', () => {
expect(sameTemplateValue({ a: false, b: [0] }, { b: [0], a: false })).toBe(true);
expect(sameTemplateValue({}, { a: null })).toBe(false);
expect(sameTemplateValue([], {})).toBe(false);
});
test('normalizes newline lists at save even without a blur event', () => {
const listField = { ...field, type: 'string-list' as const };
expect(templateFieldSaveValue(listField, ['', ' ', 'one ', ' two'])).toEqual(['one', 'two']);
expect(templateFieldSaveValue(listField, [''])).toEqual([]);
});
test('mixed selectors retain booleans and signed numeric settings accept negative limits', () => {
const select = { ...field, type: 'select' as const, options: [{ value: false, label: 'Enable' }, { value: true, label: 'Disable' }, { value: 'chat', label: 'Chat' }] };
expect(templateFieldValidation(select, false)).toBeNull();
expect(templateFieldValidation(select, 'false')).not.toBeNull();
const number = { ...field, type: 'number' as const, min: -1 };
expect(templateFieldValidation(number, -1)).toBeNull();
expect(templateFieldValidation(number, -2)).not.toBeNull();
expect(templateFieldValidation(number, '')).not.toBeNull();
});
test('accepts native bootstrap seconds and an explicitly empty fallback image model', () => {
const timeout = oauthTemplateGroups.find(group => group.id === 'oauth-codex')!.fields.find(field => field.path.at(-1) === 'stream-bootstrap-timeout')!;
for (const value of ['', ' ', '15', '0', '20s', '500ms', 'off', 'unlimited']) expect(templateFieldValidation(timeout, value)).toBeNull();
expect(templateFieldValidation(timeout, 'invalid')).not.toBeNull();
const imageModel = requestTemplateGroups.find(group => group.id === 'multimedia')!.fields.find(field => field.path.at(-1) === 'gpt-image-2-base-model')!;
expect(templateFieldValidation(imageModel, '')).toBeNull();
expect(templateFieldValidation(imageModel, 'gpt-5.4-mini')).toBeNull();
expect(templateFieldValidation(imageModel, 'invalid')).not.toBeNull();
});
test('rejects unsafe path writes', () => {
expect(() => applyTemplateChanges({}, [change(['__proto__', 'polluted'], true)])).toThrow('Invalid configuration path');
expect(() => applyTemplateChanges({}, [change([], true)])).toThrow('Invalid configuration path');
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
});
});
describe('extended browser mock persistence', () => {
test('hot-reloadable discovery, diagnostics and plugins do not ask for a restart', async () => {
const runtime = createBrowserMockRuntime('running');
const changes = [change(['server', 'discovery', 'enabled'], true), change(['observability', 'pprof', 'enable'], true), change(['plugins', 'enabled'], true), change(['plugins', 'dir'], 'custom-plugins')];
expect(await runtime.invoke('save_extended_core_config', { changes })).toMatchObject({ restartRequired: false });
});
test('credential directory changes and restoring defaults require a restart', async () => {
const runtime = createBrowserMockRuntime('running');
const path = ['oauth', 'auth-dir'];
expect(await runtime.invoke('save_extended_core_config', { changes: [change(path, '~/custom-credentials')] })).toMatchObject({ restartRequired: true });
expect(readTemplatePath(await runtime.invoke('get_extended_core_config'), path).value).toBe('~/custom-credentials');
expect(await runtime.invoke('save_extended_core_config', { changes: [change(path, null, '~/custom-credentials', true, true)] })).toMatchObject({ restartRequired: true });
expect(readTemplatePath(await runtime.invoke('get_extended_core_config'), path).exists).toBe(false);
});
test('saves offline, supports explicit overrides, removal and preserves unrelated values', async () => {
const events: string[] = [];
const runtime = createBrowserMockRuntime('stopped', (event) => events.push(event));
const changes = [change(['plugins', 'enabled'], false), change(['plugins', 'configs'], {}), change(['server', 'trusted-proxies'], [])];
const result = await runtime.invoke('save_extended_core_config', { changes }) as { config: Record<string, unknown>; restartRequired: boolean };
expect(readTemplatePath(result.config, ['plugins', 'enabled'])).toEqual({ exists: true, value: false });
expect(readTemplatePath(result.config, ['plugins', 'configs']).value).toEqual({});
expect(result.restartRequired).toBe(true);
expect(events).toContain('config-files-changed');
await runtime.invoke('save_extended_core_config', { changes: [change(['plugins', 'enabled'], null, false, true, true)] });
const current = await runtime.invoke('get_extended_core_config');
expect(readTemplatePath(current, ['plugins', 'enabled']).exists).toBe(false);
expect(readTemplatePath(current, ['server', 'host']).value).toBe('127.0.0.1');
});
test('conflicts are atomic and absent differs from explicit null', async () => {
const runtime = createBrowserMockRuntime('running');
const path = ['oauth', 'auth-auto-refresh-workers'];
await runtime.invoke('save_extended_core_config', { changes: [change(path, 2)] });
await expect(runtime.invoke('save_extended_core_config', { changes: [change(['plugins', 'enabled'], true), change(path, 5)] })).rejects.toThrow('changed externally');
const current = await runtime.invoke('get_extended_core_config');
expect(readTemplatePath(current, ['plugins', 'enabled']).exists).toBe(false);
expect(readTemplatePath(current, path).value).toBe(2);
await runtime.invoke('save_extended_core_config', { changes: [change(['plugins', 'configs'], null)] });
await expect(runtime.invoke('save_extended_core_config', { changes: [change(['plugins', 'configs'], {})] })).rejects.toThrow('changed externally');
});
test('existing and extended controls stay synchronized', async () => {
const runtime = createBrowserMockRuntime('running');
await runtime.invoke('save_retry_settings', { settings: { maxRetryInterval: -1 } });
expect(readTemplatePath(await runtime.invoke('get_extended_core_config'), ['routing', 'retry', 'max-retry-interval']).value).toBe(-1);
await runtime.invoke('save_extended_core_config', { changes: [change(['observability', 'logs', 'request-log'], true), change(['plugins', 'enabled'], true)] });
expect(await runtime.invoke('get_core_config_settings')).toMatchObject({ requestLog: true, pluginsEnabled: true });
await runtime.invoke('save_extended_core_config', { changes: [change(['observability', 'logs', 'request-log'], null, true, true, true)] });
expect(await runtime.invoke('get_core_config_settings')).toMatchObject({ requestLog: false, pluginsEnabled: true });
});
});