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 #[serde(skip_serializing_if = "Option::is_none")]
111 pub auth_token: Option<String>,
112 #[serde(skip_serializing_if = "Option::is_none")]
114 pub auth_token_env: Option<String>,
115 #[serde(skip_serializing_if = "Option::is_none")]
117 pub api_key: Option<String>,
118 #[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 #[serde(default)]
162 pub tags: HashMap<String, String>,
163 #[serde(
165 default,
166 skip_serializing_if = "HashMap::is_empty",
167 alias = "supportedModels"
168 )]
169 pub supported_models: HashMap<String, bool>,
170 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct ServiceConfig {
250 #[serde(default)]
252 pub name: String,
253 #[serde(default)]
255 pub alias: Option<String>,
256 #[serde(default = "default_service_config_enabled")]
258 pub enabled: bool,
259 #[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 #[serde(default)]
290 pub active: Option<String>,
291 #[serde(default)]
293 pub default_profile: Option<String>,
294 #[serde(default)]
296 pub profiles: BTreeMap<String, ServiceControlProfile>,
297 #[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 .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 pub min_duration_ms: u64,
358 pub global_cooldown_ms: u64,
360 pub merge_window_ms: u64,
362 pub per_thread_cooldown_ms: u64,
364 pub recent_search_window_ms: u64,
366 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 pub enabled: bool,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize, Default)]
390pub struct NotifyExecConfig {
391 pub enabled: bool,
393 #[serde(default)]
396 pub command: Vec<String>,
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize, Default)]
400pub struct NotifyConfig {
401 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 #[serde(default)]
415 pub version: Option<u32>,
416 #[serde(default)]
418 pub codex: ServiceConfigManager,
419 #[serde(default)]
421 pub claude: ServiceConfigManager,
422 #[serde(default)]
424 pub retry: RetryConfig,
425 #[serde(default)]
427 pub notify: NotifyConfig,
428 #[serde(default)]
430 pub default_service: Option<ServiceKind>,
431 #[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 #[serde(default)]
1262 pub language: Option<String>,
1263}
1264
1265pub 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
1304pub fn codex_sessions_dir() -> PathBuf {
1306 codex_home().join("sessions")
1307}
1308
1309#[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;