Skip to main content

codex_helper_core/
config.rs

1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::env;
3use std::fs as stdfs;
4use std::path::{Path, PathBuf};
5
6use crate::client_config::{codex_home, is_claude_absent_backup_sentinel};
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9use serde_json::Value as JsonValue;
10use tokio::fs;
11use toml::Value as TomlValue;
12use tracing::{info, warn};
13
14pub use crate::client_config::{
15    claude_settings_backup_path, claude_settings_path, codex_auth_path, codex_config_path,
16    codex_models_cache_path, codex_switch_state_path,
17};
18pub use crate::fleet::registry::{FleetNodeConfig, FleetRegistryConfig};
19
20#[path = "config_storage.rs"]
21mod storage_impl;
22
23#[path = "config_bootstrap.rs"]
24mod bootstrap_impl;
25
26#[path = "config_auth_sync.rs"]
27mod auth_sync_impl;
28
29#[path = "config_retry.rs"]
30mod retry_impl;
31
32#[path = "config_profiles.rs"]
33mod profiles_impl;
34
35#[path = "config_routing.rs"]
36mod routing_impl;
37
38#[path = "config_v2.rs"]
39mod v2_impl;
40
41#[path = "config_v4.rs"]
42mod v4_impl;
43
44pub use auth_sync_impl::{
45    SyncCodexAuthFromCodexOptions, SyncCodexAuthFromCodexReport, sync_codex_auth_from_codex_cli,
46};
47pub(crate) use auth_sync_impl::{infer_env_key_from_auth_json, read_file_if_exists};
48pub use bootstrap_impl::{
49    import_codex_config_from_codex_cli, load_or_bootstrap_for_service,
50    load_or_bootstrap_for_service_with_v4_source, load_or_bootstrap_from_claude,
51    load_or_bootstrap_from_codex, overwrite_codex_config_from_codex_cli_in_place,
52    probe_codex_bootstrap_from_cli,
53};
54pub(crate) use profiles_impl::validate_service_profiles;
55pub use profiles_impl::{
56    ServiceControlProfile, resolve_service_profile, resolve_service_profile_from_catalog,
57    validate_profile_station_compatibility,
58};
59pub use retry_impl::{
60    ReasoningGuardAction, ReasoningGuardConfig, ReasoningGuardRetryExhaustedAction,
61    ReasoningGuardStreamMode, ResolvedReasoningGuardConfig, ResolvedRetryConfig,
62    ResolvedRetryLayerConfig, RetryConfig, RetryLayerConfig, RetryProfileName, RetryStrategy,
63};
64pub use routing_impl::{RoutingCandidate, ServiceRoutingExplanation, explain_service_routing};
65pub use storage_impl::{
66    CodexClientPatchConfig, LoadedProxyConfig, codex_client_patch_config_from_config_file,
67    codex_client_patch_mode_from_config_file, codex_client_patch_preset_from_config_file,
68    config_file_path, init_config_toml, load_config, load_config_with_v4_source,
69    normalize_config_toml_authoring, normalize_config_toml_client_patch, save_config,
70    save_config_v2, save_config_v4,
71};
72pub use v2_impl::{
73    build_persisted_provider_catalog, build_persisted_station_catalog, compact_v2_config,
74    compile_v2_to_runtime, migrate_legacy_to_v2,
75};
76pub(crate) use v4_impl::compact_v4_config_for_write;
77pub use v4_impl::{
78    ConfigV4MigrationReport, collect_route_graph_affinity_migration_warnings,
79    compile_v4_to_runtime, compile_v4_to_v2, effective_v4_routing, migrate_legacy_to_v4,
80    migrate_legacy_to_v4_with_report, migrate_v2_to_v4, migrate_v2_to_v4_with_report,
81    resolved_v4_provider_order,
82};
83
84pub mod legacy {
85    pub use super::v4_impl::legacy::*;
86}
87
88#[cfg(test)]
89use bootstrap_impl::bootstrap_from_codex;
90
91pub mod storage {
92    pub use super::storage_impl::{
93        CodexClientPatchConfig, LoadedProxyConfig, codex_client_patch_config_from_config_file,
94        codex_client_patch_mode_from_config_file, codex_client_patch_preset_from_config_file,
95        config_file_path, init_config_toml, load_config, load_config_with_v4_source,
96        normalize_config_toml_authoring, normalize_config_toml_client_patch, save_config,
97        save_config_v2, save_config_v4,
98    };
99}
100
101pub mod bootstrap {
102    pub use super::bootstrap_impl::{
103        import_codex_config_from_codex_cli, load_or_bootstrap_for_service,
104        load_or_bootstrap_from_claude, load_or_bootstrap_from_codex,
105        overwrite_codex_config_from_codex_cli_in_place, probe_codex_bootstrap_from_cli,
106    };
107}
108
109pub mod auth_sync {
110    pub use super::auth_sync_impl::{
111        SyncCodexAuthFromCodexOptions, SyncCodexAuthFromCodexReport, sync_codex_auth_from_codex_cli,
112    };
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, Default)]
116pub struct UpstreamAuth {
117    /// Bearer token, e.g. OpenAI style
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub auth_token: Option<String>,
120    /// Environment variable name for bearer token (preferred over storing secrets on disk)
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub auth_token_env: Option<String>,
123    /// Optional API key header for some providers
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub api_key: Option<String>,
126    /// Environment variable name for API key header value
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub api_key_env: Option<String>,
129}
130
131impl UpstreamAuth {
132    pub fn resolve_auth_token(&self) -> Option<String> {
133        if let Some(token) = self.auth_token.as_deref()
134            && !token.trim().is_empty()
135        {
136            return Some(token.to_string());
137        }
138        if let Some(env_name) = self.auth_token_env.as_deref()
139            && let Ok(v) = env::var(env_name)
140            && !v.trim().is_empty()
141        {
142            return Some(v);
143        }
144        None
145    }
146
147    pub fn resolve_api_key(&self) -> Option<String> {
148        if let Some(key) = self.api_key.as_deref()
149            && !key.trim().is_empty()
150        {
151            return Some(key.to_string());
152        }
153        if let Some(env_name) = self.api_key_env.as_deref()
154            && let Ok(v) = env::var(env_name)
155            && !v.trim().is_empty()
156        {
157            return Some(v);
158        }
159        None
160    }
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct UpstreamConfig {
165    pub base_url: String,
166    #[serde(default)]
167    pub auth: UpstreamAuth,
168    /// Optional free-form metadata, e.g. region / label
169    #[serde(default)]
170    pub tags: HashMap<String, String>,
171    /// Optional model whitelist for this upstream (exact or wildcard patterns like `gpt-*`).
172    #[serde(
173        default,
174        skip_serializing_if = "HashMap::is_empty",
175        alias = "supportedModels"
176    )]
177    pub supported_models: HashMap<String, bool>,
178    /// Optional model mapping: external model name -> upstream-specific model name (supports wildcards).
179    #[serde(
180        default,
181        skip_serializing_if = "HashMap::is_empty",
182        alias = "modelMapping"
183    )]
184    pub model_mapping: HashMap<String, String>,
185}
186
187pub fn model_routing_warnings(cfg: &ProxyConfig, service_name: &str) -> Vec<String> {
188    use crate::model_routing::match_wildcard;
189
190    fn validate_upstream(name: &str, upstream: &UpstreamConfig) -> Vec<String> {
191        let mut out = Vec::new();
192
193        if upstream.supported_models.is_empty() && upstream.model_mapping.is_empty() {
194            out.push(format!(
195                "[{name}] 未配置 supported_models 或 model_mapping,将假设支持所有模型(可能导致降级失败)"
196            ));
197            return out;
198        }
199
200        if !upstream.model_mapping.is_empty() && upstream.supported_models.is_empty() {
201            out.push(format!(
202                "[{name}] 配置了 model_mapping 但未配置 supported_models,映射目标将不做校验,请确认目标模型在供应商处可用"
203            ));
204        }
205
206        if upstream.model_mapping.is_empty() || upstream.supported_models.is_empty() {
207            return out;
208        }
209
210        for (external_model, internal_model) in upstream.model_mapping.iter() {
211            if internal_model.contains('*') {
212                continue;
213            }
214            let supported = if upstream
215                .supported_models
216                .get(internal_model)
217                .copied()
218                .unwrap_or(false)
219            {
220                true
221            } else {
222                upstream
223                    .supported_models
224                    .keys()
225                    .any(|p| match_wildcard(p, internal_model))
226            };
227            if !supported {
228                out.push(format!(
229                    "[{name}] 模型映射无效:'{external_model}' -> '{internal_model}',目标模型不在 supported_models 中"
230                ));
231            }
232        }
233        out
234    }
235
236    let mgr = match service_name {
237        "claude" => &cfg.claude,
238        "codex" => &cfg.codex,
239        _ => &cfg.codex,
240    };
241
242    let mut warnings = Vec::new();
243    for (cfg_name, svc) in mgr.stations() {
244        for (idx, upstream) in svc.upstreams.iter().enumerate() {
245            let name = format!(
246                "{service_name}:{cfg_name} upstream[{idx}] ({})",
247                upstream.base_url
248            );
249            warnings.extend(validate_upstream(&name, upstream));
250        }
251    }
252    warnings
253}
254
255/// A logical config entry (roughly corresponds to cli_proxy 的一个配置名)
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct ServiceConfig {
258    /// 配置标识(map key),保持稳定
259    #[serde(default)]
260    pub name: String,
261    /// 可选别名,便于展示/记忆
262    #[serde(default)]
263    pub alias: Option<String>,
264    /// Whether this config is eligible for automatic routing (defaults to true).
265    #[serde(default = "default_service_config_enabled")]
266    pub enabled: bool,
267    /// Priority group (1..=10, lower is higher priority). Default: 1.
268    #[serde(default = "default_service_config_level")]
269    pub level: u8,
270    #[serde(default)]
271    pub upstreams: Vec<UpstreamConfig>,
272}
273
274fn default_service_config_enabled() -> bool {
275    true
276}
277
278fn is_default_service_config_enabled(value: &bool) -> bool {
279    *value == default_service_config_enabled()
280}
281
282fn default_service_config_level() -> u8 {
283    1
284}
285
286fn default_provider_endpoint_priority() -> u32 {
287    0
288}
289
290fn is_default_provider_endpoint_priority(value: &u32) -> bool {
291    *value == default_provider_endpoint_priority()
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)]
295pub struct ProviderConcurrencyLimits {
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub max_concurrent_requests: Option<u32>,
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub limit_group: Option<String>,
300}
301
302fn is_default_provider_concurrency_limits(value: &ProviderConcurrencyLimits) -> bool {
303    value == &ProviderConcurrencyLimits::default()
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize, Default)]
307pub struct ServiceConfigManager {
308    /// 当前激活配置名
309    #[serde(default)]
310    pub active: Option<String>,
311    /// 新会话默认使用的控制模板名(Phase 1: 仅加载与展示,不自动绑定)。
312    #[serde(default)]
313    pub default_profile: Option<String>,
314    /// 可复用控制模板。
315    #[serde(default)]
316    pub profiles: BTreeMap<String, ServiceControlProfile>,
317    /// 站点集合。公共序列化使用 `stations`,仍兼容读取 legacy `configs`。
318    #[serde(default, rename = "stations", alias = "configs")]
319    pub configs: HashMap<String, ServiceConfig>,
320}
321
322impl ServiceConfigManager {
323    pub fn stations(&self) -> &HashMap<String, ServiceConfig> {
324        &self.configs
325    }
326
327    pub fn stations_mut(&mut self) -> &mut HashMap<String, ServiceConfig> {
328        &mut self.configs
329    }
330
331    pub fn station(&self, name: &str) -> Option<&ServiceConfig> {
332        self.stations().get(name)
333    }
334
335    pub fn station_mut(&mut self, name: &str) -> Option<&mut ServiceConfig> {
336        self.stations_mut().get_mut(name)
337    }
338
339    pub fn contains_station(&self, name: &str) -> bool {
340        self.station(name).is_some()
341    }
342
343    pub fn station_count(&self) -> usize {
344        self.stations().len()
345    }
346
347    pub fn has_stations(&self) -> bool {
348        !self.stations().is_empty()
349    }
350
351    pub fn active_station(&self) -> Option<&ServiceConfig> {
352        self.active
353            .as_ref()
354            .and_then(|name| self.station(name))
355            // HashMap 的 values().next() 是非确定性的;这里用 key 排序后的最小项作为稳定兜底。
356            .or_else(|| {
357                self.stations()
358                    .iter()
359                    .min_by_key(|(k, _)| *k)
360                    .map(|(_, v)| v)
361            })
362    }
363
364    pub fn profile(&self, name: &str) -> Option<&ServiceControlProfile> {
365        self.profiles.get(name)
366    }
367
368    pub fn default_profile_ref(&self) -> Option<(&str, &ServiceControlProfile)> {
369        let name = self.default_profile.as_deref()?;
370        self.profile(name).map(|profile| (name, profile))
371    }
372}
373
374#[derive(Debug, Clone, Serialize, Deserialize)]
375pub struct NotifyPolicyConfig {
376    /// Only notify when proxy duration_ms is >= this threshold.
377    pub min_duration_ms: u64,
378    /// At most one notification per global_cooldown_ms.
379    pub global_cooldown_ms: u64,
380    /// Events within this window will be merged into one notification.
381    pub merge_window_ms: u64,
382    /// Suppress notifications for the same thread-id within this cooldown.
383    pub per_thread_cooldown_ms: u64,
384    /// How far back to look in proxy recent-finished list when matching a thread-id.
385    pub recent_search_window_ms: u64,
386    /// Timeout for calling proxy `status/recent` endpoint.
387    pub recent_endpoint_timeout_ms: u64,
388}
389
390impl Default for NotifyPolicyConfig {
391    fn default() -> Self {
392        Self {
393            min_duration_ms: 60_000,
394            global_cooldown_ms: 60_000,
395            merge_window_ms: 10_000,
396            per_thread_cooldown_ms: 180_000,
397            recent_search_window_ms: 5 * 60_000,
398            recent_endpoint_timeout_ms: 500,
399        }
400    }
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize, Default)]
404pub struct NotifySystemConfig {
405    /// Whether to show system notifications (toasts). Default: false.
406    pub enabled: bool,
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize, Default)]
410pub struct NotifyExecConfig {
411    /// Enable executing an external command for each aggregated notification.
412    pub enabled: bool,
413    /// Command to execute; the aggregated JSON is written to stdin.
414    /// Example: ["python", "my_script.py"].
415    #[serde(default)]
416    pub command: Vec<String>,
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize, Default)]
420pub struct NotifyConfig {
421    /// Whether notify processing is enabled at all (system toast and exec are both disabled by default).
422    pub enabled: bool,
423    #[serde(default)]
424    pub policy: NotifyPolicyConfig,
425    #[serde(default)]
426    pub system: NotifySystemConfig,
427    #[serde(default)]
428    pub exec: NotifyExecConfig,
429}
430
431fn default_usage_forecast_enabled() -> bool {
432    true
433}
434
435fn default_usage_forecast_rate_window_minutes() -> u64 {
436    60
437}
438
439fn default_usage_forecast_min_priced_requests() -> u64 {
440    2
441}
442
443fn default_usage_forecast_reset_time() -> String {
444    "00:00".to_string()
445}
446
447fn default_usage_forecast_reset_utc_offset() -> String {
448    "+08:00".to_string()
449}
450
451#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
452pub struct UsageForecastConfig {
453    /// Whether spend forecast rendering is enabled.
454    #[serde(
455        default = "default_usage_forecast_enabled",
456        skip_serializing_if = "is_default_usage_forecast_enabled"
457    )]
458    pub enabled: bool,
459    /// Rolling spend-rate window used for projection.
460    #[serde(
461        default = "default_usage_forecast_rate_window_minutes",
462        skip_serializing_if = "is_default_usage_forecast_rate_window_minutes"
463    )]
464    pub rate_window_minutes: u64,
465    /// Minimum priced requests before the forecast is treated as confident enough to show.
466    #[serde(
467        default = "default_usage_forecast_min_priced_requests",
468        skip_serializing_if = "is_default_usage_forecast_min_priced_requests"
469    )]
470    pub min_priced_requests: u64,
471    /// Daily quota reset clock time in the reset timezone, formatted as HH:MM.
472    #[serde(
473        default = "default_usage_forecast_reset_time",
474        skip_serializing_if = "is_default_usage_forecast_reset_time"
475    )]
476    pub reset_time: String,
477    /// Reset timezone as a fixed UTC offset, for example +08:00.
478    #[serde(
479        default = "default_usage_forecast_reset_utc_offset",
480        skip_serializing_if = "is_default_usage_forecast_reset_utc_offset"
481    )]
482    pub reset_utc_offset: String,
483}
484
485impl Default for UsageForecastConfig {
486    fn default() -> Self {
487        Self {
488            enabled: default_usage_forecast_enabled(),
489            rate_window_minutes: default_usage_forecast_rate_window_minutes(),
490            min_priced_requests: default_usage_forecast_min_priced_requests(),
491            reset_time: default_usage_forecast_reset_time(),
492            reset_utc_offset: default_usage_forecast_reset_utc_offset(),
493        }
494    }
495}
496
497fn is_default_usage_forecast_enabled(value: &bool) -> bool {
498    *value == default_usage_forecast_enabled()
499}
500
501fn is_default_usage_forecast_rate_window_minutes(value: &u64) -> bool {
502    *value == default_usage_forecast_rate_window_minutes()
503}
504
505fn is_default_usage_forecast_min_priced_requests(value: &u64) -> bool {
506    *value == default_usage_forecast_min_priced_requests()
507}
508
509fn is_default_usage_forecast_reset_time(value: &String) -> bool {
510    value == &default_usage_forecast_reset_time()
511}
512
513fn is_default_usage_forecast_reset_utc_offset(value: &String) -> bool {
514    value == &default_usage_forecast_reset_utc_offset()
515}
516
517fn is_default_usage_forecast_config(value: &UsageForecastConfig) -> bool {
518    value == &UsageForecastConfig::default()
519}
520
521fn default_service_status_refresh_interval_secs() -> u64 {
522    60
523}
524
525fn default_service_status_timeout_ms() -> u64 {
526    3_000
527}
528
529fn default_service_status_high_latency_ms() -> u64 {
530    3_000
531}
532
533fn default_service_status_history_cells() -> usize {
534    60
535}
536
537#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
538#[serde(default)]
539pub struct ServiceStatusConfig {
540    #[serde(default, skip_serializing_if = "bool_is_false")]
541    pub enabled: bool,
542    #[serde(
543        default = "default_service_status_refresh_interval_secs",
544        skip_serializing_if = "is_default_service_status_refresh_interval_secs"
545    )]
546    pub refresh_interval_secs: u64,
547    #[serde(
548        default = "default_service_status_timeout_ms",
549        skip_serializing_if = "is_default_service_status_timeout_ms"
550    )]
551    pub timeout_ms: u64,
552    #[serde(
553        default = "default_service_status_high_latency_ms",
554        skip_serializing_if = "is_default_service_status_high_latency_ms"
555    )]
556    pub high_latency_ms: u64,
557    #[serde(
558        default = "default_service_status_history_cells",
559        skip_serializing_if = "is_default_service_status_history_cells"
560    )]
561    pub history_cells: usize,
562    #[serde(default, skip_serializing_if = "Vec::is_empty")]
563    pub probes: Vec<ServiceStatusProbeConfig>,
564}
565
566impl Default for ServiceStatusConfig {
567    fn default() -> Self {
568        Self {
569            enabled: false,
570            refresh_interval_secs: default_service_status_refresh_interval_secs(),
571            timeout_ms: default_service_status_timeout_ms(),
572            high_latency_ms: default_service_status_high_latency_ms(),
573            history_cells: default_service_status_history_cells(),
574            probes: Vec::new(),
575        }
576    }
577}
578
579impl ServiceStatusConfig {
580    pub fn has_probes(&self) -> bool {
581        self.probes.iter().any(|probe| {
582            probe
583                .provider
584                .as_deref()
585                .is_some_and(|value| !value.trim().is_empty())
586                || probe
587                    .url
588                    .as_deref()
589                    .is_some_and(|value| !value.trim().is_empty())
590        })
591    }
592
593    pub fn is_active(&self) -> bool {
594        self.enabled && self.has_probes()
595    }
596}
597
598#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
599#[serde(default)]
600pub struct ServiceStatusProbeConfig {
601    #[serde(default, skip_serializing_if = "Option::is_none")]
602    pub id: Option<String>,
603    #[serde(default, skip_serializing_if = "Option::is_none")]
604    pub provider: Option<String>,
605    #[serde(default, skip_serializing_if = "Option::is_none")]
606    pub endpoint: Option<String>,
607    #[serde(default, skip_serializing_if = "Option::is_none")]
608    pub url: Option<String>,
609    #[serde(default, skip_serializing_if = "Vec::is_empty")]
610    pub models: Vec<String>,
611    #[serde(default, skip_serializing_if = "Option::is_none")]
612    pub timeout_ms: Option<u64>,
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    pub high_latency_ms: Option<u64>,
615    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
616    pub headers: BTreeMap<String, String>,
617}
618
619fn is_default_service_status_config(value: &ServiceStatusConfig) -> bool {
620    value == &ServiceStatusConfig::default()
621}
622
623fn bool_is_false(value: &bool) -> bool {
624    !*value
625}
626
627fn is_default_service_status_refresh_interval_secs(value: &u64) -> bool {
628    *value == default_service_status_refresh_interval_secs()
629}
630
631fn is_default_service_status_timeout_ms(value: &u64) -> bool {
632    *value == default_service_status_timeout_ms()
633}
634
635fn is_default_service_status_high_latency_ms(value: &u64) -> bool {
636    *value == default_service_status_high_latency_ms()
637}
638
639fn is_default_service_status_history_cells(value: &usize) -> bool {
640    *value == default_service_status_history_cells()
641}
642
643#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
644#[serde(default)]
645pub struct RelayTargetConfig {
646    #[serde(skip_serializing_if = "Option::is_none")]
647    pub service: Option<ServiceKind>,
648    pub proxy_url: String,
649    #[serde(skip_serializing_if = "Option::is_none")]
650    pub admin_url: Option<String>,
651    #[serde(skip_serializing_if = "Option::is_none")]
652    pub admin_token_env: Option<String>,
653    #[serde(skip_serializing_if = "Option::is_none")]
654    pub client_preset: Option<crate::codex_integration::CodexPatchMode>,
655    #[serde(skip_serializing_if = "Option::is_none")]
656    pub responses_websocket: Option<bool>,
657}
658
659#[derive(Debug, Clone, Serialize, Deserialize, Default)]
660pub struct ProxyConfig {
661    /// Optional config schema version for future migrations
662    #[serde(default)]
663    pub version: Option<u32>,
664    /// Codex 服务配置
665    #[serde(default)]
666    pub codex: ServiceConfigManager,
667    /// Claude Code 等其他服务配置,后续扩展
668    #[serde(default)]
669    pub claude: ServiceConfigManager,
670    /// Global retry policy (proxy-side).
671    #[serde(default)]
672    pub retry: RetryConfig,
673    /// Notify integration settings (used by `codex-helper notify ...`).
674    #[serde(default)]
675    pub notify: NotifyConfig,
676    /// 默认目标服务(用于 CLI 默认选择 codex/claude)
677    #[serde(default)]
678    pub default_service: Option<ServiceKind>,
679    /// Named local or remote relay targets used by daily CLI/TUI workflows.
680    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
681    pub relay_targets: BTreeMap<String, RelayTargetConfig>,
682    /// Optional fleet observer targets used by the read-only Codex Fleet panel.
683    #[serde(default, skip_serializing_if = "FleetRegistryConfig::is_empty")]
684    pub fleet: FleetRegistryConfig,
685    /// UI settings (mainly for the built-in TUI).
686    #[serde(default)]
687    pub ui: UiConfig,
688}
689
690fn default_proxy_config_v2_version() -> u32 {
691    2
692}
693
694pub const LEGACY_ROUTE_GRAPH_CONFIG_VERSION: u32 = 4;
695pub const CURRENT_ROUTE_GRAPH_CONFIG_VERSION: u32 = 5;
696
697pub fn is_supported_route_graph_config_version(version: u32) -> bool {
698    matches!(
699        version,
700        LEGACY_ROUTE_GRAPH_CONFIG_VERSION | CURRENT_ROUTE_GRAPH_CONFIG_VERSION
701    )
702}
703
704fn default_proxy_config_v4_version() -> u32 {
705    CURRENT_ROUTE_GRAPH_CONFIG_VERSION
706}
707
708#[derive(Debug, Clone, Serialize, Deserialize)]
709pub struct ProxyConfigV2 {
710    #[serde(default = "default_proxy_config_v2_version")]
711    pub version: u32,
712    #[serde(default)]
713    pub codex: ServiceViewV2,
714    #[serde(default)]
715    pub claude: ServiceViewV2,
716    #[serde(default)]
717    pub retry: RetryConfig,
718    #[serde(default)]
719    pub notify: NotifyConfig,
720    #[serde(default)]
721    pub default_service: Option<ServiceKind>,
722    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
723    pub relay_targets: BTreeMap<String, RelayTargetConfig>,
724    #[serde(default, skip_serializing_if = "FleetRegistryConfig::is_empty")]
725    pub fleet: FleetRegistryConfig,
726    #[serde(default)]
727    pub ui: UiConfig,
728}
729
730impl Default for ProxyConfigV2 {
731    fn default() -> Self {
732        Self {
733            version: default_proxy_config_v2_version(),
734            codex: ServiceViewV2::default(),
735            claude: ServiceViewV2::default(),
736            retry: RetryConfig::default(),
737            notify: NotifyConfig::default(),
738            default_service: None,
739            relay_targets: BTreeMap::new(),
740            fleet: FleetRegistryConfig::default(),
741            ui: UiConfig::default(),
742        }
743    }
744}
745
746#[derive(Debug, Clone, Serialize, Deserialize)]
747pub struct ProxyConfigV4 {
748    #[serde(default = "default_proxy_config_v4_version")]
749    pub version: u32,
750    #[serde(default)]
751    pub codex: ServiceViewV4,
752    #[serde(default)]
753    pub claude: ServiceViewV4,
754    #[serde(default)]
755    pub retry: RetryConfig,
756    #[serde(default)]
757    pub notify: NotifyConfig,
758    #[serde(default)]
759    pub default_service: Option<ServiceKind>,
760    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
761    pub relay_targets: BTreeMap<String, RelayTargetConfig>,
762    #[serde(default, skip_serializing_if = "FleetRegistryConfig::is_empty")]
763    pub fleet: FleetRegistryConfig,
764    #[serde(default)]
765    pub ui: UiConfig,
766}
767
768impl Default for ProxyConfigV4 {
769    fn default() -> Self {
770        Self {
771            version: default_proxy_config_v4_version(),
772            codex: ServiceViewV4::default(),
773            claude: ServiceViewV4::default(),
774            retry: RetryConfig::default(),
775            notify: NotifyConfig::default(),
776            default_service: None,
777            relay_targets: BTreeMap::new(),
778            fleet: FleetRegistryConfig::default(),
779            ui: UiConfig::default(),
780        }
781    }
782}
783
784#[derive(Debug, Clone, Serialize, Deserialize, Default)]
785pub struct ServiceViewV4 {
786    #[serde(default, skip_serializing_if = "Option::is_none")]
787    pub default_profile: Option<String>,
788    #[serde(default, skip_serializing_if = "is_default_codex_compaction_config")]
789    pub compaction: CodexCompactionConfig,
790    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
791    pub profiles: BTreeMap<String, ServiceControlProfile>,
792    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
793    pub providers: BTreeMap<String, ProviderConfigV4>,
794    #[serde(default, skip_serializing_if = "Option::is_none")]
795    pub routing: Option<RoutingConfigV4>,
796}
797
798impl ServiceViewV4 {
799    pub fn ensure_routing_mut(&mut self) -> &mut RoutingConfigV4 {
800        self.routing.get_or_insert_with(RoutingConfigV4::default)
801    }
802
803    pub fn normalize_routing_authoring(&mut self) {
804        if let Some(routing) = self.routing.as_mut() {
805            routing.normalize_authoring();
806        }
807    }
808}
809
810#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
811#[serde(default)]
812pub struct CodexCompactionConfig {
813    #[serde(
814        default = "default_codex_remote_v2_downgrade",
815        skip_serializing_if = "is_default_codex_remote_v2_downgrade"
816    )]
817    pub remote_v2_downgrade: bool,
818}
819
820impl Default for CodexCompactionConfig {
821    fn default() -> Self {
822        Self {
823            remote_v2_downgrade: default_codex_remote_v2_downgrade(),
824        }
825    }
826}
827
828fn default_codex_remote_v2_downgrade() -> bool {
829    true
830}
831
832fn is_default_codex_remote_v2_downgrade(value: &bool) -> bool {
833    *value == default_codex_remote_v2_downgrade()
834}
835
836fn is_default_codex_compaction_config(value: &CodexCompactionConfig) -> bool {
837    value == &CodexCompactionConfig::default()
838}
839
840#[derive(Debug, Clone, Serialize, Deserialize)]
841pub struct ProviderConfigV4 {
842    #[serde(default, skip_serializing_if = "Option::is_none")]
843    pub alias: Option<String>,
844    #[serde(
845        default = "default_service_config_enabled",
846        skip_serializing_if = "is_default_service_config_enabled"
847    )]
848    pub enabled: bool,
849    #[serde(default, skip_serializing_if = "Option::is_none")]
850    pub base_url: Option<String>,
851    #[serde(default, skip_serializing_if = "Option::is_none")]
852    pub continuity_domain: Option<String>,
853    #[serde(default, skip_serializing_if = "is_default_upstream_auth")]
854    pub auth: UpstreamAuth,
855    #[serde(default, flatten)]
856    pub inline_auth: UpstreamAuth,
857    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
858    pub tags: BTreeMap<String, String>,
859    #[serde(
860        default,
861        skip_serializing_if = "BTreeMap::is_empty",
862        alias = "supportedModels"
863    )]
864    pub supported_models: BTreeMap<String, bool>,
865    #[serde(
866        default,
867        skip_serializing_if = "BTreeMap::is_empty",
868        alias = "modelMapping"
869    )]
870    pub model_mapping: BTreeMap<String, String>,
871    #[serde(
872        default,
873        skip_serializing_if = "is_default_provider_concurrency_limits"
874    )]
875    pub limits: ProviderConcurrencyLimits,
876    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
877    pub endpoints: BTreeMap<String, ProviderEndpointV4>,
878}
879
880impl Default for ProviderConfigV4 {
881    fn default() -> Self {
882        Self {
883            alias: None,
884            enabled: default_service_config_enabled(),
885            base_url: None,
886            continuity_domain: None,
887            auth: UpstreamAuth::default(),
888            inline_auth: UpstreamAuth::default(),
889            tags: BTreeMap::new(),
890            supported_models: BTreeMap::new(),
891            model_mapping: BTreeMap::new(),
892            limits: ProviderConcurrencyLimits::default(),
893            endpoints: BTreeMap::new(),
894        }
895    }
896}
897
898#[derive(Debug, Clone, Serialize, Deserialize)]
899pub struct ProviderEndpointV4 {
900    pub base_url: String,
901    #[serde(default, skip_serializing_if = "Option::is_none")]
902    pub continuity_domain: Option<String>,
903    #[serde(
904        default = "default_service_config_enabled",
905        skip_serializing_if = "is_default_service_config_enabled"
906    )]
907    pub enabled: bool,
908    #[serde(
909        default = "default_provider_endpoint_priority",
910        skip_serializing_if = "is_default_provider_endpoint_priority"
911    )]
912    pub priority: u32,
913    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
914    pub tags: BTreeMap<String, String>,
915    #[serde(
916        default,
917        skip_serializing_if = "BTreeMap::is_empty",
918        alias = "supportedModels"
919    )]
920    pub supported_models: BTreeMap<String, bool>,
921    #[serde(
922        default,
923        skip_serializing_if = "BTreeMap::is_empty",
924        alias = "modelMapping"
925    )]
926    pub model_mapping: BTreeMap<String, String>,
927    #[serde(
928        default,
929        skip_serializing_if = "is_default_provider_concurrency_limits"
930    )]
931    pub limits: ProviderConcurrencyLimits,
932}
933
934#[derive(Debug, Clone, Serialize, Deserialize)]
935pub struct RoutingConfigV4 {
936    #[serde(default = "default_routing_entry_v4")]
937    pub entry: String,
938    #[serde(default = "default_routing_affinity_policy_v5")]
939    pub affinity_policy: RoutingAffinityPolicyV5,
940    #[serde(default, skip_serializing_if = "Option::is_none")]
941    pub fallback_ttl_ms: Option<u64>,
942    #[serde(default, skip_serializing_if = "Option::is_none")]
943    pub reprobe_preferred_after_ms: Option<u64>,
944    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
945    pub routes: BTreeMap<String, RoutingNodeV4>,
946    #[serde(skip, default = "default_routing_policy_v4")]
947    pub policy: RoutingPolicyV4,
948    #[serde(skip)]
949    pub order: Vec<String>,
950    #[serde(skip)]
951    pub target: Option<String>,
952    #[serde(skip)]
953    pub prefer_tags: Vec<BTreeMap<String, String>>,
954    #[serde(skip)]
955    pub chain: Vec<String>,
956    #[serde(skip)]
957    pub pools: BTreeMap<String, RoutingPoolV4>,
958    #[serde(skip, default = "default_routing_on_exhausted_v4")]
959    pub on_exhausted: RoutingExhaustedActionV4,
960}
961
962impl Default for RoutingConfigV4 {
963    fn default() -> Self {
964        Self {
965            entry: default_routing_entry_v4(),
966            affinity_policy: default_routing_affinity_policy_v5(),
967            fallback_ttl_ms: None,
968            reprobe_preferred_after_ms: None,
969            routes: BTreeMap::new(),
970            policy: default_routing_policy_v4(),
971            order: Vec::new(),
972            target: None,
973            prefer_tags: Vec::new(),
974            chain: Vec::new(),
975            pools: BTreeMap::new(),
976            on_exhausted: default_routing_on_exhausted_v4(),
977        }
978    }
979}
980
981impl ProxyConfigV4 {
982    pub fn normalize_routing_authoring(&mut self) {
983        self.codex.normalize_routing_authoring();
984        self.claude.normalize_routing_authoring();
985    }
986
987    pub fn sync_routing_compat_from_graph(&mut self) {
988        self.normalize_routing_authoring();
989    }
990}
991
992impl RoutingConfigV4 {
993    pub fn ordered_failover(children: Vec<String>) -> Self {
994        Self::single_entry_node(RoutingNodeV4 {
995            strategy: RoutingPolicyV4::OrderedFailover,
996            children,
997            ..RoutingNodeV4::default()
998        })
999    }
1000
1001    pub fn manual_sticky(target: String, children: Vec<String>) -> Self {
1002        Self::single_entry_node(RoutingNodeV4 {
1003            strategy: RoutingPolicyV4::ManualSticky,
1004            target: Some(target),
1005            children,
1006            ..RoutingNodeV4::default()
1007        })
1008    }
1009
1010    pub fn tag_preferred(
1011        children: Vec<String>,
1012        prefer_tags: Vec<BTreeMap<String, String>>,
1013        on_exhausted: RoutingExhaustedActionV4,
1014    ) -> Self {
1015        Self::single_entry_node(RoutingNodeV4 {
1016            strategy: RoutingPolicyV4::TagPreferred,
1017            children,
1018            prefer_tags,
1019            on_exhausted,
1020            ..RoutingNodeV4::default()
1021        })
1022    }
1023
1024    pub fn single_entry_node(node: RoutingNodeV4) -> Self {
1025        let entry = non_conflicting_default_route_entry(&node);
1026        let mut out = Self {
1027            routes: BTreeMap::from([(entry.clone(), node)]),
1028            entry,
1029            affinity_policy: default_routing_affinity_policy_v5(),
1030            fallback_ttl_ms: None,
1031            reprobe_preferred_after_ms: None,
1032            policy: default_routing_policy_v4(),
1033            order: Vec::new(),
1034            target: None,
1035            prefer_tags: Vec::new(),
1036            chain: Vec::new(),
1037            pools: BTreeMap::new(),
1038            on_exhausted: default_routing_on_exhausted_v4(),
1039        };
1040        out.sync_compat_from_graph();
1041        out
1042    }
1043
1044    pub fn has_compat_authoring_fields(&self) -> bool {
1045        self.policy != default_routing_policy_v4()
1046            || !self.order.is_empty()
1047            || self.target.is_some()
1048            || !self.prefer_tags.is_empty()
1049            || !self.chain.is_empty()
1050            || !self.pools.is_empty()
1051            || self.on_exhausted != default_routing_on_exhausted_v4()
1052    }
1053
1054    pub fn entry_node(&self) -> Option<&RoutingNodeV4> {
1055        self.routes.get(self.entry.as_str())
1056    }
1057
1058    pub fn entry_node_mut(&mut self) -> Option<&mut RoutingNodeV4> {
1059        self.routes.get_mut(self.entry.as_str())
1060    }
1061
1062    pub fn normalize_authoring(&mut self) {
1063        if self.routes.is_empty() {
1064            self.sync_graph_from_compat();
1065        }
1066        self.sync_compat_from_graph();
1067    }
1068
1069    pub fn ensure_graph_from_compat(&mut self) {
1070        if self.routes.is_empty() {
1071            self.sync_graph_from_compat();
1072        }
1073    }
1074
1075    pub fn ensure_entry_node_mut(&mut self) -> &mut RoutingNodeV4 {
1076        self.ensure_graph_from_compat();
1077        let entry = self.entry.clone();
1078        self.routes.entry(entry).or_default()
1079    }
1080
1081    pub fn set_entry_routing(
1082        &mut self,
1083        policy: RoutingPolicyV4,
1084        target: Option<String>,
1085        children: Vec<String>,
1086        prefer_tags: Vec<BTreeMap<String, String>>,
1087        on_exhausted: RoutingExhaustedActionV4,
1088    ) {
1089        let node = self.ensure_entry_node_mut();
1090        node.strategy = policy;
1091        node.children = children;
1092        node.target = target;
1093        node.prefer_tags = prefer_tags;
1094        node.on_exhausted = on_exhausted;
1095        if !matches!(node.strategy, RoutingPolicyV4::ManualSticky) {
1096            node.target = None;
1097        }
1098        if !matches!(node.strategy, RoutingPolicyV4::TagPreferred) {
1099            node.prefer_tags.clear();
1100        }
1101        self.sync_compat_from_graph();
1102    }
1103
1104    pub fn clear_entry_target(&mut self, children: Vec<String>) {
1105        self.set_entry_routing(
1106            RoutingPolicyV4::OrderedFailover,
1107            None,
1108            children,
1109            Vec::new(),
1110            RoutingExhaustedActionV4::Continue,
1111        );
1112    }
1113
1114    pub fn ensure_entry_order_contains(&mut self, provider_name: &str) {
1115        let node = self.ensure_entry_node_mut();
1116        if !node.children.iter().any(|name| name == provider_name) {
1117            node.children.push(provider_name.to_string());
1118        }
1119        self.sync_compat_from_graph();
1120    }
1121
1122    pub fn clear_manual_target_for(&mut self, provider_name: &str) -> bool {
1123        let node = self.ensure_entry_node_mut();
1124        let should_clear = matches!(node.strategy, RoutingPolicyV4::ManualSticky)
1125            && node.target.as_deref() == Some(provider_name);
1126        if !should_clear {
1127            return false;
1128        }
1129        node.strategy = RoutingPolicyV4::OrderedFailover;
1130        node.target = None;
1131        node.prefer_tags.clear();
1132        node.on_exhausted = RoutingExhaustedActionV4::Continue;
1133        self.sync_compat_from_graph();
1134        true
1135    }
1136
1137    pub fn remove_provider_references(&mut self, provider_name: &str) {
1138        self.ensure_graph_from_compat();
1139        for node in self.routes.values_mut() {
1140            node.children.retain(|name| name != provider_name);
1141            if node.target.as_deref() == Some(provider_name) {
1142                node.target = None;
1143                if matches!(node.strategy, RoutingPolicyV4::ManualSticky) {
1144                    node.strategy = RoutingPolicyV4::OrderedFailover;
1145                }
1146            }
1147        }
1148        self.sync_compat_from_graph();
1149    }
1150
1151    pub fn sync_compat_from_graph(&mut self) {
1152        let Some(node) = self.entry_node().cloned() else {
1153            self.policy = default_routing_policy_v4();
1154            self.order.clear();
1155            self.target = None;
1156            self.prefer_tags.clear();
1157            self.chain.clear();
1158            self.pools.clear();
1159            self.on_exhausted = default_routing_on_exhausted_v4();
1160            return;
1161        };
1162
1163        self.policy = node.strategy;
1164        self.target = node.target.clone();
1165        self.prefer_tags = node.prefer_tags.clone();
1166        self.on_exhausted = node.on_exhausted;
1167        self.order = node.children.clone();
1168    }
1169
1170    pub fn sync_graph_from_compat(&mut self) {
1171        if !self.has_compat_authoring_fields() {
1172            return;
1173        }
1174
1175        if self.routes.is_empty() {
1176            self.entry =
1177                non_conflicting_default_route_entry_from_refs(&self.order, self.target.as_deref());
1178            self.routes
1179                .insert(self.entry.clone(), RoutingNodeV4::default());
1180        }
1181
1182        let entry = self.entry.clone();
1183        let node = self.routes.entry(entry).or_default();
1184        node.strategy = self.policy;
1185        node.target = self.target.clone();
1186        node.prefer_tags = self.prefer_tags.clone();
1187        node.on_exhausted = self.on_exhausted;
1188        if !self.order.is_empty() {
1189            node.children = self.order.clone();
1190        }
1191    }
1192
1193    pub fn route_node_names(&self) -> Vec<String> {
1194        self.routes.keys().cloned().collect()
1195    }
1196
1197    pub fn route_node_references(&self, target: &str) -> Vec<String> {
1198        self.routes
1199            .iter()
1200            .filter_map(|(route_name, node)| {
1201                if node.children.iter().any(|child| child == target)
1202                    || node.target.as_deref() == Some(target)
1203                    || node.then.as_deref() == Some(target)
1204                    || node.default_route.as_deref() == Some(target)
1205                {
1206                    Some(route_name.clone())
1207                } else {
1208                    None
1209                }
1210            })
1211            .collect()
1212    }
1213
1214    pub fn rename_route_node(&mut self, old: &str, new: String) -> Result<()> {
1215        if old == new {
1216            return Ok(());
1217        }
1218        if !self.routes.contains_key(old) {
1219            anyhow::bail!("route node '{old}' does not exist");
1220        }
1221        if self.routes.contains_key(new.as_str()) {
1222            anyhow::bail!("route node '{new}' already exists");
1223        }
1224
1225        let Some(node) = self.routes.remove(old) else {
1226            anyhow::bail!("route node '{old}' does not exist");
1227        };
1228        self.routes.insert(new.clone(), node);
1229        if self.entry == old {
1230            self.entry = new.clone();
1231        }
1232        for node in self.routes.values_mut() {
1233            rewrite_route_node_refs(node, old, new.as_str());
1234        }
1235        self.sync_compat_from_graph();
1236        Ok(())
1237    }
1238
1239    pub fn delete_route_node(&mut self, name: &str) -> Result<()> {
1240        if self.entry == name {
1241            anyhow::bail!("entry route node '{name}' cannot be deleted");
1242        }
1243        if !self.routes.contains_key(name) {
1244            anyhow::bail!("route node '{name}' does not exist");
1245        }
1246        let refs = self.route_node_references(name);
1247        if !refs.is_empty() {
1248            anyhow::bail!(
1249                "route node '{name}' is still referenced by: {}",
1250                refs.join(", ")
1251            );
1252        }
1253        self.routes.remove(name);
1254        self.sync_compat_from_graph();
1255        Ok(())
1256    }
1257}
1258
1259fn default_routing_entry_v4() -> String {
1260    "main".to_string()
1261}
1262
1263fn non_conflicting_default_route_entry(node: &RoutingNodeV4) -> String {
1264    non_conflicting_default_route_entry_from_refs(&node.children, node.target.as_deref())
1265}
1266
1267fn non_conflicting_default_route_entry_from_refs(
1268    children: &[String],
1269    target: Option<&str>,
1270) -> String {
1271    let occupied = children
1272        .iter()
1273        .map(String::as_str)
1274        .chain(target)
1275        .collect::<BTreeSet<_>>();
1276    let base = default_routing_entry_v4();
1277    if !occupied.contains(base.as_str()) {
1278        return base;
1279    }
1280
1281    let mut candidate = format!("{base}_route");
1282    let mut idx = 2usize;
1283    while occupied.contains(candidate.as_str()) {
1284        candidate = format!("{base}_route_{idx}");
1285        idx += 1;
1286    }
1287    candidate
1288}
1289
1290fn rewrite_route_node_refs(node: &mut RoutingNodeV4, old: &str, new: &str) {
1291    for child in &mut node.children {
1292        if child == old {
1293            *child = new.to_string();
1294        }
1295    }
1296    if node.target.as_deref() == Some(old) {
1297        node.target = Some(new.to_string());
1298    }
1299    if node.then.as_deref() == Some(old) {
1300        node.then = Some(new.to_string());
1301    }
1302    if node.default_route.as_deref() == Some(old) {
1303        node.default_route = Some(new.to_string());
1304    }
1305}
1306
1307#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1308pub struct RoutingNodeV4 {
1309    #[serde(default = "default_routing_policy_v4")]
1310    pub strategy: RoutingPolicyV4,
1311    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1312    pub children: Vec<String>,
1313    #[serde(default, skip_serializing_if = "Option::is_none")]
1314    pub target: Option<String>,
1315    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1316    pub prefer_tags: Vec<BTreeMap<String, String>>,
1317    #[serde(default = "default_routing_on_exhausted_v4")]
1318    pub on_exhausted: RoutingExhaustedActionV4,
1319    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1320    pub metadata: BTreeMap<String, String>,
1321    #[serde(default, skip_serializing_if = "Option::is_none")]
1322    pub when: Option<RoutingConditionV4>,
1323    #[serde(default, skip_serializing_if = "Option::is_none")]
1324    pub then: Option<String>,
1325    #[serde(default, rename = "default", skip_serializing_if = "Option::is_none")]
1326    pub default_route: Option<String>,
1327}
1328
1329impl Default for RoutingNodeV4 {
1330    fn default() -> Self {
1331        Self {
1332            strategy: default_routing_policy_v4(),
1333            children: Vec::new(),
1334            target: None,
1335            prefer_tags: Vec::new(),
1336            on_exhausted: default_routing_on_exhausted_v4(),
1337            metadata: BTreeMap::new(),
1338            when: None,
1339            then: None,
1340            default_route: None,
1341        }
1342    }
1343}
1344
1345#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)]
1346pub struct RoutingConditionV4 {
1347    #[serde(default, skip_serializing_if = "Option::is_none")]
1348    pub model: Option<String>,
1349    #[serde(default, skip_serializing_if = "Option::is_none")]
1350    pub service_tier: Option<String>,
1351    #[serde(default, skip_serializing_if = "Option::is_none")]
1352    pub reasoning_effort: Option<String>,
1353    #[serde(default, skip_serializing_if = "Option::is_none")]
1354    pub path: Option<String>,
1355    #[serde(default, skip_serializing_if = "Option::is_none")]
1356    pub method: Option<String>,
1357    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1358    pub headers: BTreeMap<String, String>,
1359}
1360
1361impl RoutingConditionV4 {
1362    pub fn is_empty(&self) -> bool {
1363        self.model.is_none()
1364            && self.service_tier.is_none()
1365            && self.reasoning_effort.is_none()
1366            && self.path.is_none()
1367            && self.method.is_none()
1368            && self.headers.is_empty()
1369    }
1370}
1371
1372#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
1373#[serde(rename_all = "kebab-case")]
1374pub enum RoutingPolicyV4 {
1375    ManualSticky,
1376    OrderedFailover,
1377    TagPreferred,
1378    Conditional,
1379}
1380
1381fn default_routing_policy_v4() -> RoutingPolicyV4 {
1382    RoutingPolicyV4::OrderedFailover
1383}
1384
1385#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
1386#[serde(rename_all = "kebab-case")]
1387pub enum RoutingExhaustedActionV4 {
1388    Continue,
1389    Stop,
1390}
1391
1392fn default_routing_on_exhausted_v4() -> RoutingExhaustedActionV4 {
1393    RoutingExhaustedActionV4::Continue
1394}
1395
1396#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
1397#[serde(rename_all = "kebab-case")]
1398pub enum RoutingAffinityPolicyV5 {
1399    Off,
1400    PreferredGroup,
1401    FallbackSticky,
1402    Hard,
1403}
1404
1405fn default_routing_affinity_policy_v5() -> RoutingAffinityPolicyV5 {
1406    RoutingAffinityPolicyV5::FallbackSticky
1407}
1408
1409#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1410pub struct RoutingPoolV4 {
1411    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1412    pub providers: Vec<String>,
1413}
1414
1415#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1416pub struct PersistedRoutingProviderRef {
1417    pub name: String,
1418    #[serde(default, skip_serializing_if = "Option::is_none")]
1419    pub alias: Option<String>,
1420    #[serde(default = "default_service_config_enabled")]
1421    pub enabled: bool,
1422    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1423    pub tags: BTreeMap<String, String>,
1424}
1425
1426#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1427pub struct PersistedRoutingSpec {
1428    pub entry: String,
1429    #[serde(default = "default_routing_affinity_policy_v5")]
1430    pub affinity_policy: RoutingAffinityPolicyV5,
1431    #[serde(default, skip_serializing_if = "Option::is_none")]
1432    pub fallback_ttl_ms: Option<u64>,
1433    #[serde(default, skip_serializing_if = "Option::is_none")]
1434    pub reprobe_preferred_after_ms: Option<u64>,
1435    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1436    pub routes: BTreeMap<String, RoutingNodeV4>,
1437    #[serde(default = "default_routing_policy_v4")]
1438    pub policy: RoutingPolicyV4,
1439    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1440    pub order: Vec<String>,
1441    #[serde(default, skip_serializing_if = "Option::is_none")]
1442    pub target: Option<String>,
1443    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1444    pub prefer_tags: Vec<BTreeMap<String, String>>,
1445    #[serde(default = "default_routing_on_exhausted_v4")]
1446    pub on_exhausted: RoutingExhaustedActionV4,
1447    #[serde(default = "default_routing_policy_v4")]
1448    pub entry_strategy: RoutingPolicyV4,
1449    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1450    pub expanded_order: Vec<String>,
1451    #[serde(default, skip_serializing_if = "Option::is_none")]
1452    pub entry_target: Option<String>,
1453    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1454    pub providers: Vec<PersistedRoutingProviderRef>,
1455}
1456
1457fn is_default_upstream_auth(auth: &UpstreamAuth) -> bool {
1458    auth.auth_token.is_none()
1459        && auth.auth_token_env.is_none()
1460        && auth.api_key.is_none()
1461        && auth.api_key_env.is_none()
1462}
1463
1464#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1465pub struct ServiceViewV2 {
1466    #[serde(
1467        default,
1468        skip_serializing_if = "Option::is_none",
1469        rename = "active_station",
1470        alias = "active_group"
1471    )]
1472    pub active_group: Option<String>,
1473    #[serde(default, skip_serializing_if = "Option::is_none")]
1474    pub default_profile: Option<String>,
1475    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1476    pub profiles: BTreeMap<String, ServiceControlProfile>,
1477    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1478    pub providers: BTreeMap<String, ProviderConfigV2>,
1479    #[serde(
1480        default,
1481        skip_serializing_if = "BTreeMap::is_empty",
1482        rename = "stations",
1483        alias = "groups"
1484    )]
1485    pub groups: BTreeMap<String, GroupConfigV2>,
1486}
1487
1488#[derive(Debug, Clone, Serialize, Deserialize)]
1489pub struct ProviderConfigV2 {
1490    #[serde(default, skip_serializing_if = "Option::is_none")]
1491    pub alias: Option<String>,
1492    #[serde(default = "default_service_config_enabled")]
1493    pub enabled: bool,
1494    #[serde(default)]
1495    pub auth: UpstreamAuth,
1496    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1497    pub tags: BTreeMap<String, String>,
1498    #[serde(
1499        default,
1500        skip_serializing_if = "BTreeMap::is_empty",
1501        alias = "supportedModels"
1502    )]
1503    pub supported_models: BTreeMap<String, bool>,
1504    #[serde(
1505        default,
1506        skip_serializing_if = "BTreeMap::is_empty",
1507        alias = "modelMapping"
1508    )]
1509    pub model_mapping: BTreeMap<String, String>,
1510    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1511    pub endpoints: BTreeMap<String, ProviderEndpointV2>,
1512}
1513
1514impl Default for ProviderConfigV2 {
1515    fn default() -> Self {
1516        Self {
1517            alias: None,
1518            enabled: default_service_config_enabled(),
1519            auth: UpstreamAuth::default(),
1520            tags: BTreeMap::new(),
1521            supported_models: BTreeMap::new(),
1522            model_mapping: BTreeMap::new(),
1523            endpoints: BTreeMap::new(),
1524        }
1525    }
1526}
1527
1528#[derive(Debug, Clone, Serialize, Deserialize)]
1529pub struct ProviderEndpointV2 {
1530    pub base_url: String,
1531    #[serde(default = "default_service_config_enabled")]
1532    pub enabled: bool,
1533    #[serde(
1534        default = "default_provider_endpoint_priority",
1535        skip_serializing_if = "is_default_provider_endpoint_priority"
1536    )]
1537    pub priority: u32,
1538    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1539    pub tags: BTreeMap<String, String>,
1540    #[serde(
1541        default,
1542        skip_serializing_if = "BTreeMap::is_empty",
1543        alias = "supportedModels"
1544    )]
1545    pub supported_models: BTreeMap<String, bool>,
1546    #[serde(
1547        default,
1548        skip_serializing_if = "BTreeMap::is_empty",
1549        alias = "modelMapping"
1550    )]
1551    pub model_mapping: BTreeMap<String, String>,
1552}
1553
1554#[derive(Debug, Clone, Serialize, Deserialize)]
1555pub struct GroupConfigV2 {
1556    #[serde(default, skip_serializing_if = "Option::is_none")]
1557    pub alias: Option<String>,
1558    #[serde(default = "default_service_config_enabled")]
1559    pub enabled: bool,
1560    #[serde(default = "default_service_config_level")]
1561    pub level: u8,
1562    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1563    pub members: Vec<GroupMemberRefV2>,
1564}
1565
1566impl Default for GroupConfigV2 {
1567    fn default() -> Self {
1568        Self {
1569            alias: None,
1570            enabled: default_service_config_enabled(),
1571            level: default_service_config_level(),
1572            members: Vec::new(),
1573        }
1574    }
1575}
1576
1577#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1578pub struct GroupMemberRefV2 {
1579    pub provider: String,
1580    #[serde(default, skip_serializing_if = "Vec::is_empty", alias = "endpoints")]
1581    pub endpoint_names: Vec<String>,
1582    #[serde(default)]
1583    pub preferred: bool,
1584}
1585
1586#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1587pub struct PersistedStationProviderEndpointRef {
1588    pub name: String,
1589    pub base_url: String,
1590    #[serde(default = "default_service_config_enabled")]
1591    pub enabled: bool,
1592}
1593
1594#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1595pub struct PersistedStationProviderRef {
1596    pub name: String,
1597    #[serde(default, skip_serializing_if = "Option::is_none")]
1598    pub alias: Option<String>,
1599    #[serde(default = "default_service_config_enabled")]
1600    pub enabled: bool,
1601    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1602    pub endpoints: Vec<PersistedStationProviderEndpointRef>,
1603}
1604
1605#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1606pub struct PersistedStationSpec {
1607    pub name: String,
1608    #[serde(default, skip_serializing_if = "Option::is_none")]
1609    pub alias: Option<String>,
1610    #[serde(default = "default_service_config_enabled")]
1611    pub enabled: bool,
1612    #[serde(default = "default_service_config_level")]
1613    pub level: u8,
1614    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1615    pub members: Vec<GroupMemberRefV2>,
1616}
1617
1618#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1619pub struct PersistedStationsCatalog {
1620    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1621    pub stations: Vec<PersistedStationSpec>,
1622    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1623    pub providers: Vec<PersistedStationProviderRef>,
1624}
1625
1626#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1627pub struct PersistedProviderEndpointSpec {
1628    pub name: String,
1629    pub base_url: String,
1630    #[serde(default = "default_service_config_enabled")]
1631    pub enabled: bool,
1632    #[serde(
1633        default = "default_provider_endpoint_priority",
1634        skip_serializing_if = "is_default_provider_endpoint_priority"
1635    )]
1636    pub priority: u32,
1637    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1638    pub tags: BTreeMap<String, String>,
1639    #[serde(
1640        default,
1641        skip_serializing_if = "is_default_provider_concurrency_limits"
1642    )]
1643    pub limits: ProviderConcurrencyLimits,
1644}
1645
1646#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1647pub struct PersistedProviderSpec {
1648    pub name: String,
1649    #[serde(default, skip_serializing_if = "Option::is_none")]
1650    pub alias: Option<String>,
1651    #[serde(default = "default_service_config_enabled")]
1652    pub enabled: bool,
1653    #[serde(default, skip_serializing_if = "Option::is_none")]
1654    pub auth_token_env: Option<String>,
1655    #[serde(default, skip_serializing_if = "Option::is_none")]
1656    pub api_key_env: Option<String>,
1657    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1658    pub tags: BTreeMap<String, String>,
1659    #[serde(
1660        default,
1661        skip_serializing_if = "is_default_provider_concurrency_limits"
1662    )]
1663    pub limits: ProviderConcurrencyLimits,
1664    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1665    pub endpoints: Vec<PersistedProviderEndpointSpec>,
1666}
1667
1668#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1669pub struct PersistedProvidersCatalog {
1670    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1671    pub providers: Vec<PersistedProviderSpec>,
1672}
1673
1674#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1675pub struct UiConfig {
1676    /// UI language: `en`, `zh`, or `auto` (default: unset).
1677    ///
1678    /// When unset, codex-helper will pick a default language based on system locale for the first run.
1679    #[serde(default)]
1680    pub language: Option<String>,
1681    /// Spend-rate projection shown in operator UIs.
1682    #[serde(default, skip_serializing_if = "is_default_usage_forecast_config")]
1683    pub usage_forecast: UsageForecastConfig,
1684    /// Optional remote service status probes shown by operator UIs.
1685    #[serde(default, skip_serializing_if = "is_default_service_status_config")]
1686    pub service_status: ServiceStatusConfig,
1687}
1688
1689/// 获取 codex-helper 的主目录(用于配置、日志等)
1690pub fn proxy_home_dir() -> PathBuf {
1691    if let Ok(dir) = env::var("CODEX_HELPER_HOME") {
1692        let trimmed = dir.trim();
1693        if !trimmed.is_empty() {
1694            return PathBuf::from(trimmed);
1695        }
1696    }
1697
1698    #[cfg(test)]
1699    {
1700        static TEST_HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
1701        TEST_HOME
1702            .get_or_init(|| {
1703                let mut dir = std::env::temp_dir();
1704                let unique = format!(
1705                    "codex-helper-test-{}-{}",
1706                    std::process::id(),
1707                    std::time::SystemTime::now()
1708                        .duration_since(std::time::UNIX_EPOCH)
1709                        .map(|d| d.as_nanos())
1710                        .unwrap_or(0)
1711                );
1712                dir.push(unique);
1713                dir.push(".codex-helper");
1714                let _ = std::fs::create_dir_all(&dir);
1715                dir
1716            })
1717            .clone()
1718    }
1719
1720    #[cfg(not(test))]
1721    {
1722        dirs::home_dir()
1723            .unwrap_or_else(|| PathBuf::from("."))
1724            .join(".codex-helper")
1725    }
1726}
1727
1728/// Directory where Codex stores conversation sessions: `~/.codex/sessions` (or `$CODEX_HOME/sessions`).
1729pub fn codex_sessions_dir() -> PathBuf {
1730    codex_home().join("sessions")
1731}
1732
1733/// 支持的上游服务类型:Codex / Claude。
1734#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1735#[serde(rename_all = "lowercase")]
1736pub enum ServiceKind {
1737    Codex,
1738    Claude,
1739}
1740
1741#[cfg(test)]
1742mod tests;