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