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_switch_state_path,
17};
18
19#[path = "config_storage.rs"]
20mod storage_impl;
21
22#[path = "config_bootstrap.rs"]
23mod bootstrap_impl;
24
25#[path = "config_auth_sync.rs"]
26mod auth_sync_impl;
27
28#[path = "config_retry.rs"]
29mod retry_impl;
30
31#[path = "config_profiles.rs"]
32mod profiles_impl;
33
34#[path = "config_routing.rs"]
35mod routing_impl;
36
37#[path = "config_v2.rs"]
38mod v2_impl;
39
40#[path = "config_v4.rs"]
41mod v4_impl;
42
43pub use auth_sync_impl::{
44    SyncCodexAuthFromCodexOptions, SyncCodexAuthFromCodexReport, sync_codex_auth_from_codex_cli,
45};
46pub(crate) use auth_sync_impl::{infer_env_key_from_auth_json, read_file_if_exists};
47pub use bootstrap_impl::{
48    import_codex_config_from_codex_cli, load_or_bootstrap_for_service,
49    load_or_bootstrap_for_service_with_v4_source, load_or_bootstrap_from_claude,
50    load_or_bootstrap_from_codex, overwrite_codex_config_from_codex_cli_in_place,
51    probe_codex_bootstrap_from_cli,
52};
53pub(crate) use profiles_impl::validate_service_profiles;
54pub use profiles_impl::{
55    ServiceControlProfile, resolve_service_profile, resolve_service_profile_from_catalog,
56    validate_profile_station_compatibility,
57};
58pub use retry_impl::{
59    ResolvedRetryConfig, ResolvedRetryLayerConfig, RetryConfig, RetryLayerConfig, RetryProfileName,
60    RetryStrategy,
61};
62pub use routing_impl::{RoutingCandidate, ServiceRoutingExplanation, explain_service_routing};
63pub use storage_impl::{
64    LoadedProxyConfig, config_file_path, init_config_toml, load_config, load_config_with_v4_source,
65    save_config, save_config_v2, save_config_v4,
66};
67pub use v2_impl::{
68    build_persisted_provider_catalog, build_persisted_station_catalog, compact_v2_config,
69    compile_v2_to_runtime, migrate_legacy_to_v2,
70};
71pub(crate) use v4_impl::compact_v4_config_for_write;
72pub use v4_impl::{
73    ConfigV4MigrationReport, collect_route_graph_affinity_migration_warnings,
74    compile_v4_to_runtime, compile_v4_to_v2, effective_v4_routing, migrate_legacy_to_v4,
75    migrate_legacy_to_v4_with_report, migrate_v2_to_v4, migrate_v2_to_v4_with_report,
76    resolved_v4_provider_order,
77};
78
79pub mod legacy {
80    pub use super::v4_impl::legacy::*;
81}
82
83#[cfg(test)]
84use bootstrap_impl::bootstrap_from_codex;
85
86pub mod storage {
87    pub use super::storage_impl::{
88        LoadedProxyConfig, config_file_path, init_config_toml, load_config,
89        load_config_with_v4_source, save_config, save_config_v2, save_config_v4,
90    };
91}
92
93pub mod bootstrap {
94    pub use super::bootstrap_impl::{
95        import_codex_config_from_codex_cli, load_or_bootstrap_for_service,
96        load_or_bootstrap_from_claude, load_or_bootstrap_from_codex,
97        overwrite_codex_config_from_codex_cli_in_place, probe_codex_bootstrap_from_cli,
98    };
99}
100
101pub mod auth_sync {
102    pub use super::auth_sync_impl::{
103        SyncCodexAuthFromCodexOptions, SyncCodexAuthFromCodexReport, sync_codex_auth_from_codex_cli,
104    };
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, Default)]
108pub struct UpstreamAuth {
109    /// Bearer token, e.g. OpenAI style
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub auth_token: Option<String>,
112    /// Environment variable name for bearer token (preferred over storing secrets on disk)
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub auth_token_env: Option<String>,
115    /// Optional API key header for some providers
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub api_key: Option<String>,
118    /// Environment variable name for API key header value
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub api_key_env: Option<String>,
121}
122
123impl UpstreamAuth {
124    pub fn resolve_auth_token(&self) -> Option<String> {
125        if let Some(token) = self.auth_token.as_deref()
126            && !token.trim().is_empty()
127        {
128            return Some(token.to_string());
129        }
130        if let Some(env_name) = self.auth_token_env.as_deref()
131            && let Ok(v) = env::var(env_name)
132            && !v.trim().is_empty()
133        {
134            return Some(v);
135        }
136        None
137    }
138
139    pub fn resolve_api_key(&self) -> Option<String> {
140        if let Some(key) = self.api_key.as_deref()
141            && !key.trim().is_empty()
142        {
143            return Some(key.to_string());
144        }
145        if let Some(env_name) = self.api_key_env.as_deref()
146            && let Ok(v) = env::var(env_name)
147            && !v.trim().is_empty()
148        {
149            return Some(v);
150        }
151        None
152    }
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct UpstreamConfig {
157    pub base_url: String,
158    #[serde(default)]
159    pub auth: UpstreamAuth,
160    /// Optional free-form metadata, e.g. region / label
161    #[serde(default)]
162    pub tags: HashMap<String, String>,
163    /// Optional model whitelist for this upstream (exact or wildcard patterns like `gpt-*`).
164    #[serde(
165        default,
166        skip_serializing_if = "HashMap::is_empty",
167        alias = "supportedModels"
168    )]
169    pub supported_models: HashMap<String, bool>,
170    /// Optional model mapping: external model name -> upstream-specific model name (supports wildcards).
171    #[serde(
172        default,
173        skip_serializing_if = "HashMap::is_empty",
174        alias = "modelMapping"
175    )]
176    pub model_mapping: HashMap<String, String>,
177}
178
179pub fn model_routing_warnings(cfg: &ProxyConfig, service_name: &str) -> Vec<String> {
180    use crate::model_routing::match_wildcard;
181
182    fn validate_upstream(name: &str, upstream: &UpstreamConfig) -> Vec<String> {
183        let mut out = Vec::new();
184
185        if upstream.supported_models.is_empty() && upstream.model_mapping.is_empty() {
186            out.push(format!(
187                "[{name}] 未配置 supported_models 或 model_mapping,将假设支持所有模型(可能导致降级失败)"
188            ));
189            return out;
190        }
191
192        if !upstream.model_mapping.is_empty() && upstream.supported_models.is_empty() {
193            out.push(format!(
194                "[{name}] 配置了 model_mapping 但未配置 supported_models,映射目标将不做校验,请确认目标模型在供应商处可用"
195            ));
196        }
197
198        if upstream.model_mapping.is_empty() || upstream.supported_models.is_empty() {
199            return out;
200        }
201
202        for (external_model, internal_model) in upstream.model_mapping.iter() {
203            if internal_model.contains('*') {
204                continue;
205            }
206            let supported = if upstream
207                .supported_models
208                .get(internal_model)
209                .copied()
210                .unwrap_or(false)
211            {
212                true
213            } else {
214                upstream
215                    .supported_models
216                    .keys()
217                    .any(|p| match_wildcard(p, internal_model))
218            };
219            if !supported {
220                out.push(format!(
221                    "[{name}] 模型映射无效:'{external_model}' -> '{internal_model}',目标模型不在 supported_models 中"
222                ));
223            }
224        }
225        out
226    }
227
228    let mgr = match service_name {
229        "claude" => &cfg.claude,
230        "codex" => &cfg.codex,
231        _ => &cfg.codex,
232    };
233
234    let mut warnings = Vec::new();
235    for (cfg_name, svc) in mgr.stations() {
236        for (idx, upstream) in svc.upstreams.iter().enumerate() {
237            let name = format!(
238                "{service_name}:{cfg_name} upstream[{idx}] ({})",
239                upstream.base_url
240            );
241            warnings.extend(validate_upstream(&name, upstream));
242        }
243    }
244    warnings
245}
246
247/// A logical config entry (roughly corresponds to cli_proxy 的一个配置名)
248#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct ServiceConfig {
250    /// 配置标识(map key),保持稳定
251    #[serde(default)]
252    pub name: String,
253    /// 可选别名,便于展示/记忆
254    #[serde(default)]
255    pub alias: Option<String>,
256    /// Whether this config is eligible for automatic routing (defaults to true).
257    #[serde(default = "default_service_config_enabled")]
258    pub enabled: bool,
259    /// Priority group (1..=10, lower is higher priority). Default: 1.
260    #[serde(default = "default_service_config_level")]
261    pub level: u8,
262    #[serde(default)]
263    pub upstreams: Vec<UpstreamConfig>,
264}
265
266fn default_service_config_enabled() -> bool {
267    true
268}
269
270fn is_default_service_config_enabled(value: &bool) -> bool {
271    *value == default_service_config_enabled()
272}
273
274fn default_service_config_level() -> u8 {
275    1
276}
277
278fn default_provider_endpoint_priority() -> u32 {
279    0
280}
281
282fn is_default_provider_endpoint_priority(value: &u32) -> bool {
283    *value == default_provider_endpoint_priority()
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize, Default)]
287pub struct ServiceConfigManager {
288    /// 当前激活配置名
289    #[serde(default)]
290    pub active: Option<String>,
291    /// 新会话默认使用的控制模板名(Phase 1: 仅加载与展示,不自动绑定)。
292    #[serde(default)]
293    pub default_profile: Option<String>,
294    /// 可复用控制模板。
295    #[serde(default)]
296    pub profiles: BTreeMap<String, ServiceControlProfile>,
297    /// 站点集合。公共序列化使用 `stations`,仍兼容读取 legacy `configs`。
298    #[serde(default, rename = "stations", alias = "configs")]
299    pub configs: HashMap<String, ServiceConfig>,
300}
301
302impl ServiceConfigManager {
303    pub fn stations(&self) -> &HashMap<String, ServiceConfig> {
304        &self.configs
305    }
306
307    pub fn stations_mut(&mut self) -> &mut HashMap<String, ServiceConfig> {
308        &mut self.configs
309    }
310
311    pub fn station(&self, name: &str) -> Option<&ServiceConfig> {
312        self.stations().get(name)
313    }
314
315    pub fn station_mut(&mut self, name: &str) -> Option<&mut ServiceConfig> {
316        self.stations_mut().get_mut(name)
317    }
318
319    pub fn contains_station(&self, name: &str) -> bool {
320        self.station(name).is_some()
321    }
322
323    pub fn station_count(&self) -> usize {
324        self.stations().len()
325    }
326
327    pub fn has_stations(&self) -> bool {
328        !self.stations().is_empty()
329    }
330
331    pub fn active_station(&self) -> Option<&ServiceConfig> {
332        self.active
333            .as_ref()
334            .and_then(|name| self.station(name))
335            // HashMap 的 values().next() 是非确定性的;这里用 key 排序后的最小项作为稳定兜底。
336            .or_else(|| {
337                self.stations()
338                    .iter()
339                    .min_by_key(|(k, _)| *k)
340                    .map(|(_, v)| v)
341            })
342    }
343
344    pub fn profile(&self, name: &str) -> Option<&ServiceControlProfile> {
345        self.profiles.get(name)
346    }
347
348    pub fn default_profile_ref(&self) -> Option<(&str, &ServiceControlProfile)> {
349        let name = self.default_profile.as_deref()?;
350        self.profile(name).map(|profile| (name, profile))
351    }
352}
353
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct NotifyPolicyConfig {
356    /// Only notify when proxy duration_ms is >= this threshold.
357    pub min_duration_ms: u64,
358    /// At most one notification per global_cooldown_ms.
359    pub global_cooldown_ms: u64,
360    /// Events within this window will be merged into one notification.
361    pub merge_window_ms: u64,
362    /// Suppress notifications for the same thread-id within this cooldown.
363    pub per_thread_cooldown_ms: u64,
364    /// How far back to look in proxy recent-finished list when matching a thread-id.
365    pub recent_search_window_ms: u64,
366    /// Timeout for calling proxy `status/recent` endpoint.
367    pub recent_endpoint_timeout_ms: u64,
368}
369
370impl Default for NotifyPolicyConfig {
371    fn default() -> Self {
372        Self {
373            min_duration_ms: 60_000,
374            global_cooldown_ms: 60_000,
375            merge_window_ms: 10_000,
376            per_thread_cooldown_ms: 180_000,
377            recent_search_window_ms: 5 * 60_000,
378            recent_endpoint_timeout_ms: 500,
379        }
380    }
381}
382
383#[derive(Debug, Clone, Serialize, Deserialize, Default)]
384pub struct NotifySystemConfig {
385    /// Whether to show system notifications (toasts). Default: false.
386    pub enabled: bool,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize, Default)]
390pub struct NotifyExecConfig {
391    /// Enable executing an external command for each aggregated notification.
392    pub enabled: bool,
393    /// Command to execute; the aggregated JSON is written to stdin.
394    /// Example: ["python", "my_script.py"].
395    #[serde(default)]
396    pub command: Vec<String>,
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize, Default)]
400pub struct NotifyConfig {
401    /// Whether notify processing is enabled at all (system toast and exec are both disabled by default).
402    pub enabled: bool,
403    #[serde(default)]
404    pub policy: NotifyPolicyConfig,
405    #[serde(default)]
406    pub system: NotifySystemConfig,
407    #[serde(default)]
408    pub exec: NotifyExecConfig,
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize, Default)]
412pub struct ProxyConfig {
413    /// Optional config schema version for future migrations
414    #[serde(default)]
415    pub version: Option<u32>,
416    /// Codex 服务配置
417    #[serde(default)]
418    pub codex: ServiceConfigManager,
419    /// Claude Code 等其他服务配置,后续扩展
420    #[serde(default)]
421    pub claude: ServiceConfigManager,
422    /// Global retry policy (proxy-side).
423    #[serde(default)]
424    pub retry: RetryConfig,
425    /// Notify integration settings (used by `codex-helper notify ...`).
426    #[serde(default)]
427    pub notify: NotifyConfig,
428    /// 默认目标服务(用于 CLI 默认选择 codex/claude)
429    #[serde(default)]
430    pub default_service: Option<ServiceKind>,
431    /// UI settings (mainly for the built-in TUI).
432    #[serde(default)]
433    pub ui: UiConfig,
434}
435
436fn default_proxy_config_v2_version() -> u32 {
437    2
438}
439
440pub const LEGACY_ROUTE_GRAPH_CONFIG_VERSION: u32 = 4;
441pub const CURRENT_ROUTE_GRAPH_CONFIG_VERSION: u32 = 5;
442
443pub fn is_supported_route_graph_config_version(version: u32) -> bool {
444    matches!(
445        version,
446        LEGACY_ROUTE_GRAPH_CONFIG_VERSION | CURRENT_ROUTE_GRAPH_CONFIG_VERSION
447    )
448}
449
450fn default_proxy_config_v4_version() -> u32 {
451    CURRENT_ROUTE_GRAPH_CONFIG_VERSION
452}
453
454#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct ProxyConfigV2 {
456    #[serde(default = "default_proxy_config_v2_version")]
457    pub version: u32,
458    #[serde(default)]
459    pub codex: ServiceViewV2,
460    #[serde(default)]
461    pub claude: ServiceViewV2,
462    #[serde(default)]
463    pub retry: RetryConfig,
464    #[serde(default)]
465    pub notify: NotifyConfig,
466    #[serde(default)]
467    pub default_service: Option<ServiceKind>,
468    #[serde(default)]
469    pub ui: UiConfig,
470}
471
472impl Default for ProxyConfigV2 {
473    fn default() -> Self {
474        Self {
475            version: default_proxy_config_v2_version(),
476            codex: ServiceViewV2::default(),
477            claude: ServiceViewV2::default(),
478            retry: RetryConfig::default(),
479            notify: NotifyConfig::default(),
480            default_service: None,
481            ui: UiConfig::default(),
482        }
483    }
484}
485
486#[derive(Debug, Clone, Serialize, Deserialize)]
487pub struct ProxyConfigV4 {
488    #[serde(default = "default_proxy_config_v4_version")]
489    pub version: u32,
490    #[serde(default)]
491    pub codex: ServiceViewV4,
492    #[serde(default)]
493    pub claude: ServiceViewV4,
494    #[serde(default)]
495    pub retry: RetryConfig,
496    #[serde(default)]
497    pub notify: NotifyConfig,
498    #[serde(default)]
499    pub default_service: Option<ServiceKind>,
500    #[serde(default)]
501    pub ui: UiConfig,
502}
503
504impl Default for ProxyConfigV4 {
505    fn default() -> Self {
506        Self {
507            version: default_proxy_config_v4_version(),
508            codex: ServiceViewV4::default(),
509            claude: ServiceViewV4::default(),
510            retry: RetryConfig::default(),
511            notify: NotifyConfig::default(),
512            default_service: None,
513            ui: UiConfig::default(),
514        }
515    }
516}
517
518#[derive(Debug, Clone, Serialize, Deserialize, Default)]
519pub struct ServiceViewV4 {
520    #[serde(default, skip_serializing_if = "Option::is_none")]
521    pub default_profile: Option<String>,
522    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
523    pub profiles: BTreeMap<String, ServiceControlProfile>,
524    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
525    pub providers: BTreeMap<String, ProviderConfigV4>,
526    #[serde(default, skip_serializing_if = "Option::is_none")]
527    pub routing: Option<RoutingConfigV4>,
528}
529
530#[derive(Debug, Clone, Serialize, Deserialize)]
531pub struct ProviderConfigV4 {
532    #[serde(default, skip_serializing_if = "Option::is_none")]
533    pub alias: Option<String>,
534    #[serde(
535        default = "default_service_config_enabled",
536        skip_serializing_if = "is_default_service_config_enabled"
537    )]
538    pub enabled: bool,
539    #[serde(default, skip_serializing_if = "Option::is_none")]
540    pub base_url: Option<String>,
541    #[serde(default, skip_serializing_if = "is_default_upstream_auth")]
542    pub auth: UpstreamAuth,
543    #[serde(default, flatten)]
544    pub inline_auth: UpstreamAuth,
545    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
546    pub tags: BTreeMap<String, String>,
547    #[serde(
548        default,
549        skip_serializing_if = "BTreeMap::is_empty",
550        alias = "supportedModels"
551    )]
552    pub supported_models: BTreeMap<String, bool>,
553    #[serde(
554        default,
555        skip_serializing_if = "BTreeMap::is_empty",
556        alias = "modelMapping"
557    )]
558    pub model_mapping: BTreeMap<String, String>,
559    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
560    pub endpoints: BTreeMap<String, ProviderEndpointV4>,
561}
562
563impl Default for ProviderConfigV4 {
564    fn default() -> Self {
565        Self {
566            alias: None,
567            enabled: default_service_config_enabled(),
568            base_url: None,
569            auth: UpstreamAuth::default(),
570            inline_auth: UpstreamAuth::default(),
571            tags: BTreeMap::new(),
572            supported_models: BTreeMap::new(),
573            model_mapping: BTreeMap::new(),
574            endpoints: BTreeMap::new(),
575        }
576    }
577}
578
579#[derive(Debug, Clone, Serialize, Deserialize)]
580pub struct ProviderEndpointV4 {
581    pub base_url: String,
582    #[serde(
583        default = "default_service_config_enabled",
584        skip_serializing_if = "is_default_service_config_enabled"
585    )]
586    pub enabled: bool,
587    #[serde(
588        default = "default_provider_endpoint_priority",
589        skip_serializing_if = "is_default_provider_endpoint_priority"
590    )]
591    pub priority: u32,
592    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
593    pub tags: BTreeMap<String, String>,
594    #[serde(
595        default,
596        skip_serializing_if = "BTreeMap::is_empty",
597        alias = "supportedModels"
598    )]
599    pub supported_models: BTreeMap<String, bool>,
600    #[serde(
601        default,
602        skip_serializing_if = "BTreeMap::is_empty",
603        alias = "modelMapping"
604    )]
605    pub model_mapping: BTreeMap<String, String>,
606}
607
608#[derive(Debug, Clone, Serialize, Deserialize)]
609pub struct RoutingConfigV4 {
610    #[serde(default = "default_routing_entry_v4")]
611    pub entry: String,
612    #[serde(
613        default = "default_routing_affinity_policy_v5",
614        skip_serializing_if = "is_default_routing_affinity_policy_v5"
615    )]
616    pub affinity_policy: RoutingAffinityPolicyV5,
617    #[serde(default, skip_serializing_if = "Option::is_none")]
618    pub fallback_ttl_ms: Option<u64>,
619    #[serde(default, skip_serializing_if = "Option::is_none")]
620    pub reprobe_preferred_after_ms: Option<u64>,
621    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
622    pub routes: BTreeMap<String, RoutingNodeV4>,
623    #[serde(skip, default = "default_routing_policy_v4")]
624    pub policy: RoutingPolicyV4,
625    #[serde(skip)]
626    pub order: Vec<String>,
627    #[serde(skip)]
628    pub target: Option<String>,
629    #[serde(skip)]
630    pub prefer_tags: Vec<BTreeMap<String, String>>,
631    #[serde(skip)]
632    pub chain: Vec<String>,
633    #[serde(skip)]
634    pub pools: BTreeMap<String, RoutingPoolV4>,
635    #[serde(skip, default = "default_routing_on_exhausted_v4")]
636    pub on_exhausted: RoutingExhaustedActionV4,
637}
638
639impl Default for RoutingConfigV4 {
640    fn default() -> Self {
641        Self {
642            entry: default_routing_entry_v4(),
643            affinity_policy: default_routing_affinity_policy_v5(),
644            fallback_ttl_ms: None,
645            reprobe_preferred_after_ms: None,
646            routes: BTreeMap::new(),
647            policy: default_routing_policy_v4(),
648            order: Vec::new(),
649            target: None,
650            prefer_tags: Vec::new(),
651            chain: Vec::new(),
652            pools: BTreeMap::new(),
653            on_exhausted: default_routing_on_exhausted_v4(),
654        }
655    }
656}
657
658impl ProxyConfigV4 {
659    pub fn sync_routing_compat_from_graph(&mut self) {
660        if let Some(routing) = self.codex.routing.as_mut() {
661            routing.sync_compat_from_graph();
662        }
663        if let Some(routing) = self.claude.routing.as_mut() {
664            routing.sync_compat_from_graph();
665        }
666    }
667}
668
669impl RoutingConfigV4 {
670    pub fn ordered_failover(children: Vec<String>) -> Self {
671        Self::single_entry_node(RoutingNodeV4 {
672            strategy: RoutingPolicyV4::OrderedFailover,
673            children,
674            ..RoutingNodeV4::default()
675        })
676    }
677
678    pub fn manual_sticky(target: String, children: Vec<String>) -> Self {
679        Self::single_entry_node(RoutingNodeV4 {
680            strategy: RoutingPolicyV4::ManualSticky,
681            target: Some(target),
682            children,
683            ..RoutingNodeV4::default()
684        })
685    }
686
687    pub fn tag_preferred(
688        children: Vec<String>,
689        prefer_tags: Vec<BTreeMap<String, String>>,
690        on_exhausted: RoutingExhaustedActionV4,
691    ) -> Self {
692        Self::single_entry_node(RoutingNodeV4 {
693            strategy: RoutingPolicyV4::TagPreferred,
694            children,
695            prefer_tags,
696            on_exhausted,
697            ..RoutingNodeV4::default()
698        })
699    }
700
701    pub fn single_entry_node(node: RoutingNodeV4) -> Self {
702        let entry = non_conflicting_default_route_entry(&node);
703        let mut out = Self {
704            routes: BTreeMap::from([(entry.clone(), node)]),
705            entry,
706            affinity_policy: default_routing_affinity_policy_v5(),
707            fallback_ttl_ms: None,
708            reprobe_preferred_after_ms: None,
709            policy: default_routing_policy_v4(),
710            order: Vec::new(),
711            target: None,
712            prefer_tags: Vec::new(),
713            chain: Vec::new(),
714            pools: BTreeMap::new(),
715            on_exhausted: default_routing_on_exhausted_v4(),
716        };
717        out.sync_compat_from_graph();
718        out
719    }
720
721    pub fn has_compat_authoring_fields(&self) -> bool {
722        self.policy != default_routing_policy_v4()
723            || !self.order.is_empty()
724            || self.target.is_some()
725            || !self.prefer_tags.is_empty()
726            || !self.chain.is_empty()
727            || !self.pools.is_empty()
728            || self.on_exhausted != default_routing_on_exhausted_v4()
729    }
730
731    pub fn entry_node(&self) -> Option<&RoutingNodeV4> {
732        self.routes.get(self.entry.as_str())
733    }
734
735    pub fn entry_node_mut(&mut self) -> Option<&mut RoutingNodeV4> {
736        self.routes.get_mut(self.entry.as_str())
737    }
738
739    pub fn sync_compat_from_graph(&mut self) {
740        let Some(node) = self.entry_node().cloned() else {
741            self.policy = default_routing_policy_v4();
742            self.order.clear();
743            self.target = None;
744            self.prefer_tags.clear();
745            self.chain.clear();
746            self.pools.clear();
747            self.on_exhausted = default_routing_on_exhausted_v4();
748            return;
749        };
750
751        self.policy = node.strategy;
752        self.target = node.target.clone();
753        self.prefer_tags = node.prefer_tags.clone();
754        self.on_exhausted = node.on_exhausted;
755        self.order = node.children.clone();
756    }
757
758    pub fn sync_graph_from_compat(&mut self) {
759        if !self.has_compat_authoring_fields() {
760            return;
761        }
762
763        if self.routes.is_empty() {
764            self.entry =
765                non_conflicting_default_route_entry_from_refs(&self.order, self.target.as_deref());
766            self.routes
767                .insert(self.entry.clone(), RoutingNodeV4::default());
768        }
769
770        let entry = self.entry.clone();
771        let node = self.routes.entry(entry).or_default();
772        node.strategy = self.policy;
773        node.target = self.target.clone();
774        node.prefer_tags = self.prefer_tags.clone();
775        node.on_exhausted = self.on_exhausted;
776        if !self.order.is_empty() {
777            node.children = self.order.clone();
778        }
779    }
780
781    pub fn route_node_names(&self) -> Vec<String> {
782        self.routes.keys().cloned().collect()
783    }
784
785    pub fn route_node_references(&self, target: &str) -> Vec<String> {
786        self.routes
787            .iter()
788            .filter_map(|(route_name, node)| {
789                if node.children.iter().any(|child| child == target)
790                    || node.target.as_deref() == Some(target)
791                    || node.then.as_deref() == Some(target)
792                    || node.default_route.as_deref() == Some(target)
793                {
794                    Some(route_name.clone())
795                } else {
796                    None
797                }
798            })
799            .collect()
800    }
801
802    pub fn rename_route_node(&mut self, old: &str, new: String) -> Result<()> {
803        if old == new {
804            return Ok(());
805        }
806        if !self.routes.contains_key(old) {
807            anyhow::bail!("route node '{old}' does not exist");
808        }
809        if self.routes.contains_key(new.as_str()) {
810            anyhow::bail!("route node '{new}' already exists");
811        }
812
813        let Some(node) = self.routes.remove(old) else {
814            anyhow::bail!("route node '{old}' does not exist");
815        };
816        self.routes.insert(new.clone(), node);
817        if self.entry == old {
818            self.entry = new.clone();
819        }
820        for node in self.routes.values_mut() {
821            rewrite_route_node_refs(node, old, new.as_str());
822        }
823        self.sync_compat_from_graph();
824        Ok(())
825    }
826
827    pub fn delete_route_node(&mut self, name: &str) -> Result<()> {
828        if self.entry == name {
829            anyhow::bail!("entry route node '{name}' cannot be deleted");
830        }
831        if !self.routes.contains_key(name) {
832            anyhow::bail!("route node '{name}' does not exist");
833        }
834        let refs = self.route_node_references(name);
835        if !refs.is_empty() {
836            anyhow::bail!(
837                "route node '{name}' is still referenced by: {}",
838                refs.join(", ")
839            );
840        }
841        self.routes.remove(name);
842        self.sync_compat_from_graph();
843        Ok(())
844    }
845}
846
847fn default_routing_entry_v4() -> String {
848    "main".to_string()
849}
850
851fn non_conflicting_default_route_entry(node: &RoutingNodeV4) -> String {
852    non_conflicting_default_route_entry_from_refs(&node.children, node.target.as_deref())
853}
854
855fn non_conflicting_default_route_entry_from_refs(
856    children: &[String],
857    target: Option<&str>,
858) -> String {
859    let occupied = children
860        .iter()
861        .map(String::as_str)
862        .chain(target)
863        .collect::<BTreeSet<_>>();
864    let base = default_routing_entry_v4();
865    if !occupied.contains(base.as_str()) {
866        return base;
867    }
868
869    let mut candidate = format!("{base}_route");
870    let mut idx = 2usize;
871    while occupied.contains(candidate.as_str()) {
872        candidate = format!("{base}_route_{idx}");
873        idx += 1;
874    }
875    candidate
876}
877
878fn rewrite_route_node_refs(node: &mut RoutingNodeV4, old: &str, new: &str) {
879    for child in &mut node.children {
880        if child == old {
881            *child = new.to_string();
882        }
883    }
884    if node.target.as_deref() == Some(old) {
885        node.target = Some(new.to_string());
886    }
887    if node.then.as_deref() == Some(old) {
888        node.then = Some(new.to_string());
889    }
890    if node.default_route.as_deref() == Some(old) {
891        node.default_route = Some(new.to_string());
892    }
893}
894
895#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
896pub struct RoutingNodeV4 {
897    #[serde(default = "default_routing_policy_v4")]
898    pub strategy: RoutingPolicyV4,
899    #[serde(default, skip_serializing_if = "Vec::is_empty")]
900    pub children: Vec<String>,
901    #[serde(default, skip_serializing_if = "Option::is_none")]
902    pub target: Option<String>,
903    #[serde(default, skip_serializing_if = "Vec::is_empty")]
904    pub prefer_tags: Vec<BTreeMap<String, String>>,
905    #[serde(default = "default_routing_on_exhausted_v4")]
906    pub on_exhausted: RoutingExhaustedActionV4,
907    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
908    pub metadata: BTreeMap<String, String>,
909    #[serde(default, skip_serializing_if = "Option::is_none")]
910    pub when: Option<RoutingConditionV4>,
911    #[serde(default, skip_serializing_if = "Option::is_none")]
912    pub then: Option<String>,
913    #[serde(default, rename = "default", skip_serializing_if = "Option::is_none")]
914    pub default_route: Option<String>,
915}
916
917impl Default for RoutingNodeV4 {
918    fn default() -> Self {
919        Self {
920            strategy: default_routing_policy_v4(),
921            children: Vec::new(),
922            target: None,
923            prefer_tags: Vec::new(),
924            on_exhausted: default_routing_on_exhausted_v4(),
925            metadata: BTreeMap::new(),
926            when: None,
927            then: None,
928            default_route: None,
929        }
930    }
931}
932
933#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)]
934pub struct RoutingConditionV4 {
935    #[serde(default, skip_serializing_if = "Option::is_none")]
936    pub model: Option<String>,
937    #[serde(default, skip_serializing_if = "Option::is_none")]
938    pub service_tier: Option<String>,
939    #[serde(default, skip_serializing_if = "Option::is_none")]
940    pub reasoning_effort: Option<String>,
941    #[serde(default, skip_serializing_if = "Option::is_none")]
942    pub path: Option<String>,
943    #[serde(default, skip_serializing_if = "Option::is_none")]
944    pub method: Option<String>,
945    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
946    pub headers: BTreeMap<String, String>,
947}
948
949impl RoutingConditionV4 {
950    pub fn is_empty(&self) -> bool {
951        self.model.is_none()
952            && self.service_tier.is_none()
953            && self.reasoning_effort.is_none()
954            && self.path.is_none()
955            && self.method.is_none()
956            && self.headers.is_empty()
957    }
958}
959
960#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
961#[serde(rename_all = "kebab-case")]
962pub enum RoutingPolicyV4 {
963    ManualSticky,
964    OrderedFailover,
965    TagPreferred,
966    Conditional,
967}
968
969fn default_routing_policy_v4() -> RoutingPolicyV4 {
970    RoutingPolicyV4::OrderedFailover
971}
972
973#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
974#[serde(rename_all = "kebab-case")]
975pub enum RoutingExhaustedActionV4 {
976    Continue,
977    Stop,
978}
979
980fn default_routing_on_exhausted_v4() -> RoutingExhaustedActionV4 {
981    RoutingExhaustedActionV4::Continue
982}
983
984#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
985#[serde(rename_all = "kebab-case")]
986pub enum RoutingAffinityPolicyV5 {
987    Off,
988    PreferredGroup,
989    FallbackSticky,
990    Hard,
991}
992
993fn default_routing_affinity_policy_v5() -> RoutingAffinityPolicyV5 {
994    RoutingAffinityPolicyV5::PreferredGroup
995}
996
997fn is_default_routing_affinity_policy_v5(policy: &RoutingAffinityPolicyV5) -> bool {
998    *policy == default_routing_affinity_policy_v5()
999}
1000
1001#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1002pub struct RoutingPoolV4 {
1003    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1004    pub providers: Vec<String>,
1005}
1006
1007#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1008pub struct PersistedRoutingProviderRef {
1009    pub name: String,
1010    #[serde(default, skip_serializing_if = "Option::is_none")]
1011    pub alias: Option<String>,
1012    #[serde(default = "default_service_config_enabled")]
1013    pub enabled: bool,
1014    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1015    pub tags: BTreeMap<String, String>,
1016}
1017
1018#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1019pub struct PersistedRoutingSpec {
1020    pub entry: String,
1021    #[serde(default = "default_routing_affinity_policy_v5")]
1022    pub affinity_policy: RoutingAffinityPolicyV5,
1023    #[serde(default, skip_serializing_if = "Option::is_none")]
1024    pub fallback_ttl_ms: Option<u64>,
1025    #[serde(default, skip_serializing_if = "Option::is_none")]
1026    pub reprobe_preferred_after_ms: Option<u64>,
1027    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1028    pub routes: BTreeMap<String, RoutingNodeV4>,
1029    #[serde(default = "default_routing_policy_v4")]
1030    pub policy: RoutingPolicyV4,
1031    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1032    pub order: Vec<String>,
1033    #[serde(default, skip_serializing_if = "Option::is_none")]
1034    pub target: Option<String>,
1035    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1036    pub prefer_tags: Vec<BTreeMap<String, String>>,
1037    #[serde(default = "default_routing_on_exhausted_v4")]
1038    pub on_exhausted: RoutingExhaustedActionV4,
1039    #[serde(default = "default_routing_policy_v4")]
1040    pub entry_strategy: RoutingPolicyV4,
1041    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1042    pub expanded_order: Vec<String>,
1043    #[serde(default, skip_serializing_if = "Option::is_none")]
1044    pub entry_target: Option<String>,
1045    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1046    pub providers: Vec<PersistedRoutingProviderRef>,
1047}
1048
1049fn is_default_upstream_auth(auth: &UpstreamAuth) -> bool {
1050    auth.auth_token.is_none()
1051        && auth.auth_token_env.is_none()
1052        && auth.api_key.is_none()
1053        && auth.api_key_env.is_none()
1054}
1055
1056#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1057pub struct ServiceViewV2 {
1058    #[serde(
1059        default,
1060        skip_serializing_if = "Option::is_none",
1061        rename = "active_station",
1062        alias = "active_group"
1063    )]
1064    pub active_group: Option<String>,
1065    #[serde(default, skip_serializing_if = "Option::is_none")]
1066    pub default_profile: Option<String>,
1067    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1068    pub profiles: BTreeMap<String, ServiceControlProfile>,
1069    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1070    pub providers: BTreeMap<String, ProviderConfigV2>,
1071    #[serde(
1072        default,
1073        skip_serializing_if = "BTreeMap::is_empty",
1074        rename = "stations",
1075        alias = "groups"
1076    )]
1077    pub groups: BTreeMap<String, GroupConfigV2>,
1078}
1079
1080#[derive(Debug, Clone, Serialize, Deserialize)]
1081pub struct ProviderConfigV2 {
1082    #[serde(default, skip_serializing_if = "Option::is_none")]
1083    pub alias: Option<String>,
1084    #[serde(default = "default_service_config_enabled")]
1085    pub enabled: bool,
1086    #[serde(default)]
1087    pub auth: UpstreamAuth,
1088    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1089    pub tags: BTreeMap<String, String>,
1090    #[serde(
1091        default,
1092        skip_serializing_if = "BTreeMap::is_empty",
1093        alias = "supportedModels"
1094    )]
1095    pub supported_models: BTreeMap<String, bool>,
1096    #[serde(
1097        default,
1098        skip_serializing_if = "BTreeMap::is_empty",
1099        alias = "modelMapping"
1100    )]
1101    pub model_mapping: BTreeMap<String, String>,
1102    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1103    pub endpoints: BTreeMap<String, ProviderEndpointV2>,
1104}
1105
1106impl Default for ProviderConfigV2 {
1107    fn default() -> Self {
1108        Self {
1109            alias: None,
1110            enabled: default_service_config_enabled(),
1111            auth: UpstreamAuth::default(),
1112            tags: BTreeMap::new(),
1113            supported_models: BTreeMap::new(),
1114            model_mapping: BTreeMap::new(),
1115            endpoints: BTreeMap::new(),
1116        }
1117    }
1118}
1119
1120#[derive(Debug, Clone, Serialize, Deserialize)]
1121pub struct ProviderEndpointV2 {
1122    pub base_url: String,
1123    #[serde(default = "default_service_config_enabled")]
1124    pub enabled: bool,
1125    #[serde(
1126        default = "default_provider_endpoint_priority",
1127        skip_serializing_if = "is_default_provider_endpoint_priority"
1128    )]
1129    pub priority: u32,
1130    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1131    pub tags: BTreeMap<String, String>,
1132    #[serde(
1133        default,
1134        skip_serializing_if = "BTreeMap::is_empty",
1135        alias = "supportedModels"
1136    )]
1137    pub supported_models: BTreeMap<String, bool>,
1138    #[serde(
1139        default,
1140        skip_serializing_if = "BTreeMap::is_empty",
1141        alias = "modelMapping"
1142    )]
1143    pub model_mapping: BTreeMap<String, String>,
1144}
1145
1146#[derive(Debug, Clone, Serialize, Deserialize)]
1147pub struct GroupConfigV2 {
1148    #[serde(default, skip_serializing_if = "Option::is_none")]
1149    pub alias: Option<String>,
1150    #[serde(default = "default_service_config_enabled")]
1151    pub enabled: bool,
1152    #[serde(default = "default_service_config_level")]
1153    pub level: u8,
1154    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1155    pub members: Vec<GroupMemberRefV2>,
1156}
1157
1158impl Default for GroupConfigV2 {
1159    fn default() -> Self {
1160        Self {
1161            alias: None,
1162            enabled: default_service_config_enabled(),
1163            level: default_service_config_level(),
1164            members: Vec::new(),
1165        }
1166    }
1167}
1168
1169#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1170pub struct GroupMemberRefV2 {
1171    pub provider: String,
1172    #[serde(default, skip_serializing_if = "Vec::is_empty", alias = "endpoints")]
1173    pub endpoint_names: Vec<String>,
1174    #[serde(default)]
1175    pub preferred: bool,
1176}
1177
1178#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1179pub struct PersistedStationProviderEndpointRef {
1180    pub name: String,
1181    pub base_url: String,
1182    #[serde(default = "default_service_config_enabled")]
1183    pub enabled: bool,
1184}
1185
1186#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1187pub struct PersistedStationProviderRef {
1188    pub name: String,
1189    #[serde(default, skip_serializing_if = "Option::is_none")]
1190    pub alias: Option<String>,
1191    #[serde(default = "default_service_config_enabled")]
1192    pub enabled: bool,
1193    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1194    pub endpoints: Vec<PersistedStationProviderEndpointRef>,
1195}
1196
1197#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1198pub struct PersistedStationSpec {
1199    pub name: String,
1200    #[serde(default, skip_serializing_if = "Option::is_none")]
1201    pub alias: Option<String>,
1202    #[serde(default = "default_service_config_enabled")]
1203    pub enabled: bool,
1204    #[serde(default = "default_service_config_level")]
1205    pub level: u8,
1206    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1207    pub members: Vec<GroupMemberRefV2>,
1208}
1209
1210#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1211pub struct PersistedStationsCatalog {
1212    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1213    pub stations: Vec<PersistedStationSpec>,
1214    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1215    pub providers: Vec<PersistedStationProviderRef>,
1216}
1217
1218#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1219pub struct PersistedProviderEndpointSpec {
1220    pub name: String,
1221    pub base_url: String,
1222    #[serde(default = "default_service_config_enabled")]
1223    pub enabled: bool,
1224    #[serde(
1225        default = "default_provider_endpoint_priority",
1226        skip_serializing_if = "is_default_provider_endpoint_priority"
1227    )]
1228    pub priority: u32,
1229    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1230    pub tags: BTreeMap<String, String>,
1231}
1232
1233#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1234pub struct PersistedProviderSpec {
1235    pub name: String,
1236    #[serde(default, skip_serializing_if = "Option::is_none")]
1237    pub alias: Option<String>,
1238    #[serde(default = "default_service_config_enabled")]
1239    pub enabled: bool,
1240    #[serde(default, skip_serializing_if = "Option::is_none")]
1241    pub auth_token_env: Option<String>,
1242    #[serde(default, skip_serializing_if = "Option::is_none")]
1243    pub api_key_env: Option<String>,
1244    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1245    pub tags: BTreeMap<String, String>,
1246    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1247    pub endpoints: Vec<PersistedProviderEndpointSpec>,
1248}
1249
1250#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1251pub struct PersistedProvidersCatalog {
1252    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1253    pub providers: Vec<PersistedProviderSpec>,
1254}
1255
1256#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1257pub struct UiConfig {
1258    /// UI language: `en`, `zh`, or `auto` (default: unset).
1259    ///
1260    /// When unset, codex-helper will pick a default language based on system locale for the first run.
1261    #[serde(default)]
1262    pub language: Option<String>,
1263}
1264
1265/// 获取 codex-helper 的主目录(用于配置、日志等)
1266pub fn proxy_home_dir() -> PathBuf {
1267    if let Ok(dir) = env::var("CODEX_HELPER_HOME") {
1268        let trimmed = dir.trim();
1269        if !trimmed.is_empty() {
1270            return PathBuf::from(trimmed);
1271        }
1272    }
1273
1274    #[cfg(test)]
1275    {
1276        static TEST_HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
1277        TEST_HOME
1278            .get_or_init(|| {
1279                let mut dir = std::env::temp_dir();
1280                let unique = format!(
1281                    "codex-helper-test-{}-{}",
1282                    std::process::id(),
1283                    std::time::SystemTime::now()
1284                        .duration_since(std::time::UNIX_EPOCH)
1285                        .map(|d| d.as_nanos())
1286                        .unwrap_or(0)
1287                );
1288                dir.push(unique);
1289                dir.push(".codex-helper");
1290                let _ = std::fs::create_dir_all(&dir);
1291                dir
1292            })
1293            .clone()
1294    }
1295
1296    #[cfg(not(test))]
1297    {
1298        dirs::home_dir()
1299            .unwrap_or_else(|| PathBuf::from("."))
1300            .join(".codex-helper")
1301    }
1302}
1303
1304/// Directory where Codex stores conversation sessions: `~/.codex/sessions` (or `$CODEX_HOME/sessions`).
1305pub fn codex_sessions_dir() -> PathBuf {
1306    codex_home().join("sessions")
1307}
1308
1309/// 支持的上游服务类型:Codex / Claude。
1310#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1311#[serde(rename_all = "lowercase")]
1312pub enum ServiceKind {
1313    Codex,
1314    Claude,
1315}
1316
1317#[cfg(test)]
1318mod tests;