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 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 #[serde(skip_serializing_if = "Option::is_none")]
117 pub auth_token: Option<String>,
118 #[serde(skip_serializing_if = "Option::is_none")]
120 pub auth_token_env: Option<String>,
121 #[serde(skip_serializing_if = "Option::is_none")]
123 pub api_key: Option<String>,
124 #[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 #[serde(default)]
168 pub tags: HashMap<String, String>,
169 #[serde(
171 default,
172 skip_serializing_if = "HashMap::is_empty",
173 alias = "supportedModels"
174 )]
175 pub supported_models: HashMap<String, bool>,
176 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct ServiceConfig {
256 #[serde(default)]
258 pub name: String,
259 #[serde(default)]
261 pub alias: Option<String>,
262 #[serde(default = "default_service_config_enabled")]
264 pub enabled: bool,
265 #[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 #[serde(default)]
308 pub active: Option<String>,
309 #[serde(default)]
311 pub default_profile: Option<String>,
312 #[serde(default)]
314 pub profiles: BTreeMap<String, ServiceControlProfile>,
315 #[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 .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 pub min_duration_ms: u64,
376 pub global_cooldown_ms: u64,
378 pub merge_window_ms: u64,
380 pub per_thread_cooldown_ms: u64,
382 pub recent_search_window_ms: u64,
384 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 pub enabled: bool,
405}
406
407#[derive(Debug, Clone, Serialize, Deserialize, Default)]
408pub struct NotifyExecConfig {
409 pub enabled: bool,
411 #[serde(default)]
414 pub command: Vec<String>,
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize, Default)]
418pub struct NotifyConfig {
419 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 #[serde(
453 default = "default_usage_forecast_enabled",
454 skip_serializing_if = "is_default_usage_forecast_enabled"
455 )]
456 pub enabled: bool,
457 #[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 #[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 #[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 #[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, Default)]
520pub struct ProxyConfig {
521 #[serde(default)]
523 pub version: Option<u32>,
524 #[serde(default)]
526 pub codex: ServiceConfigManager,
527 #[serde(default)]
529 pub claude: ServiceConfigManager,
530 #[serde(default)]
532 pub retry: RetryConfig,
533 #[serde(default)]
535 pub notify: NotifyConfig,
536 #[serde(default)]
538 pub default_service: Option<ServiceKind>,
539 #[serde(default)]
541 pub ui: UiConfig,
542}
543
544fn default_proxy_config_v2_version() -> u32 {
545 2
546}
547
548pub const LEGACY_ROUTE_GRAPH_CONFIG_VERSION: u32 = 4;
549pub const CURRENT_ROUTE_GRAPH_CONFIG_VERSION: u32 = 5;
550
551pub fn is_supported_route_graph_config_version(version: u32) -> bool {
552 matches!(
553 version,
554 LEGACY_ROUTE_GRAPH_CONFIG_VERSION | CURRENT_ROUTE_GRAPH_CONFIG_VERSION
555 )
556}
557
558fn default_proxy_config_v4_version() -> u32 {
559 CURRENT_ROUTE_GRAPH_CONFIG_VERSION
560}
561
562#[derive(Debug, Clone, Serialize, Deserialize)]
563pub struct ProxyConfigV2 {
564 #[serde(default = "default_proxy_config_v2_version")]
565 pub version: u32,
566 #[serde(default)]
567 pub codex: ServiceViewV2,
568 #[serde(default)]
569 pub claude: ServiceViewV2,
570 #[serde(default)]
571 pub retry: RetryConfig,
572 #[serde(default)]
573 pub notify: NotifyConfig,
574 #[serde(default)]
575 pub default_service: Option<ServiceKind>,
576 #[serde(default)]
577 pub ui: UiConfig,
578}
579
580impl Default for ProxyConfigV2 {
581 fn default() -> Self {
582 Self {
583 version: default_proxy_config_v2_version(),
584 codex: ServiceViewV2::default(),
585 claude: ServiceViewV2::default(),
586 retry: RetryConfig::default(),
587 notify: NotifyConfig::default(),
588 default_service: None,
589 ui: UiConfig::default(),
590 }
591 }
592}
593
594#[derive(Debug, Clone, Serialize, Deserialize)]
595pub struct ProxyConfigV4 {
596 #[serde(default = "default_proxy_config_v4_version")]
597 pub version: u32,
598 #[serde(default)]
599 pub codex: ServiceViewV4,
600 #[serde(default)]
601 pub claude: ServiceViewV4,
602 #[serde(default)]
603 pub retry: RetryConfig,
604 #[serde(default)]
605 pub notify: NotifyConfig,
606 #[serde(default)]
607 pub default_service: Option<ServiceKind>,
608 #[serde(default)]
609 pub ui: UiConfig,
610}
611
612impl Default for ProxyConfigV4 {
613 fn default() -> Self {
614 Self {
615 version: default_proxy_config_v4_version(),
616 codex: ServiceViewV4::default(),
617 claude: ServiceViewV4::default(),
618 retry: RetryConfig::default(),
619 notify: NotifyConfig::default(),
620 default_service: None,
621 ui: UiConfig::default(),
622 }
623 }
624}
625
626#[derive(Debug, Clone, Serialize, Deserialize, Default)]
627pub struct ServiceViewV4 {
628 #[serde(default, skip_serializing_if = "Option::is_none")]
629 pub default_profile: Option<String>,
630 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
631 pub profiles: BTreeMap<String, ServiceControlProfile>,
632 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
633 pub providers: BTreeMap<String, ProviderConfigV4>,
634 #[serde(default, skip_serializing_if = "Option::is_none")]
635 pub routing: Option<RoutingConfigV4>,
636}
637
638#[derive(Debug, Clone, Serialize, Deserialize)]
639pub struct ProviderConfigV4 {
640 #[serde(default, skip_serializing_if = "Option::is_none")]
641 pub alias: Option<String>,
642 #[serde(
643 default = "default_service_config_enabled",
644 skip_serializing_if = "is_default_service_config_enabled"
645 )]
646 pub enabled: bool,
647 #[serde(default, skip_serializing_if = "Option::is_none")]
648 pub base_url: Option<String>,
649 #[serde(default, skip_serializing_if = "is_default_upstream_auth")]
650 pub auth: UpstreamAuth,
651 #[serde(default, flatten)]
652 pub inline_auth: UpstreamAuth,
653 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
654 pub tags: BTreeMap<String, String>,
655 #[serde(
656 default,
657 skip_serializing_if = "BTreeMap::is_empty",
658 alias = "supportedModels"
659 )]
660 pub supported_models: BTreeMap<String, bool>,
661 #[serde(
662 default,
663 skip_serializing_if = "BTreeMap::is_empty",
664 alias = "modelMapping"
665 )]
666 pub model_mapping: BTreeMap<String, String>,
667 #[serde(
668 default,
669 skip_serializing_if = "is_default_provider_concurrency_limits"
670 )]
671 pub limits: ProviderConcurrencyLimits,
672 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
673 pub endpoints: BTreeMap<String, ProviderEndpointV4>,
674}
675
676impl Default for ProviderConfigV4 {
677 fn default() -> Self {
678 Self {
679 alias: None,
680 enabled: default_service_config_enabled(),
681 base_url: None,
682 auth: UpstreamAuth::default(),
683 inline_auth: UpstreamAuth::default(),
684 tags: BTreeMap::new(),
685 supported_models: BTreeMap::new(),
686 model_mapping: BTreeMap::new(),
687 limits: ProviderConcurrencyLimits::default(),
688 endpoints: BTreeMap::new(),
689 }
690 }
691}
692
693#[derive(Debug, Clone, Serialize, Deserialize)]
694pub struct ProviderEndpointV4 {
695 pub base_url: String,
696 #[serde(
697 default = "default_service_config_enabled",
698 skip_serializing_if = "is_default_service_config_enabled"
699 )]
700 pub enabled: bool,
701 #[serde(
702 default = "default_provider_endpoint_priority",
703 skip_serializing_if = "is_default_provider_endpoint_priority"
704 )]
705 pub priority: u32,
706 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
707 pub tags: BTreeMap<String, String>,
708 #[serde(
709 default,
710 skip_serializing_if = "BTreeMap::is_empty",
711 alias = "supportedModels"
712 )]
713 pub supported_models: BTreeMap<String, bool>,
714 #[serde(
715 default,
716 skip_serializing_if = "BTreeMap::is_empty",
717 alias = "modelMapping"
718 )]
719 pub model_mapping: BTreeMap<String, String>,
720 #[serde(
721 default,
722 skip_serializing_if = "is_default_provider_concurrency_limits"
723 )]
724 pub limits: ProviderConcurrencyLimits,
725}
726
727#[derive(Debug, Clone, Serialize, Deserialize)]
728pub struct RoutingConfigV4 {
729 #[serde(default = "default_routing_entry_v4")]
730 pub entry: String,
731 #[serde(default = "default_routing_affinity_policy_v5")]
732 pub affinity_policy: RoutingAffinityPolicyV5,
733 #[serde(default, skip_serializing_if = "Option::is_none")]
734 pub fallback_ttl_ms: Option<u64>,
735 #[serde(default, skip_serializing_if = "Option::is_none")]
736 pub reprobe_preferred_after_ms: Option<u64>,
737 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
738 pub routes: BTreeMap<String, RoutingNodeV4>,
739 #[serde(skip, default = "default_routing_policy_v4")]
740 pub policy: RoutingPolicyV4,
741 #[serde(skip)]
742 pub order: Vec<String>,
743 #[serde(skip)]
744 pub target: Option<String>,
745 #[serde(skip)]
746 pub prefer_tags: Vec<BTreeMap<String, String>>,
747 #[serde(skip)]
748 pub chain: Vec<String>,
749 #[serde(skip)]
750 pub pools: BTreeMap<String, RoutingPoolV4>,
751 #[serde(skip, default = "default_routing_on_exhausted_v4")]
752 pub on_exhausted: RoutingExhaustedActionV4,
753}
754
755impl Default for RoutingConfigV4 {
756 fn default() -> Self {
757 Self {
758 entry: default_routing_entry_v4(),
759 affinity_policy: default_routing_affinity_policy_v5(),
760 fallback_ttl_ms: None,
761 reprobe_preferred_after_ms: None,
762 routes: BTreeMap::new(),
763 policy: default_routing_policy_v4(),
764 order: Vec::new(),
765 target: None,
766 prefer_tags: Vec::new(),
767 chain: Vec::new(),
768 pools: BTreeMap::new(),
769 on_exhausted: default_routing_on_exhausted_v4(),
770 }
771 }
772}
773
774impl ProxyConfigV4 {
775 pub fn sync_routing_compat_from_graph(&mut self) {
776 if let Some(routing) = self.codex.routing.as_mut() {
777 routing.sync_compat_from_graph();
778 }
779 if let Some(routing) = self.claude.routing.as_mut() {
780 routing.sync_compat_from_graph();
781 }
782 }
783}
784
785impl RoutingConfigV4 {
786 pub fn ordered_failover(children: Vec<String>) -> Self {
787 Self::single_entry_node(RoutingNodeV4 {
788 strategy: RoutingPolicyV4::OrderedFailover,
789 children,
790 ..RoutingNodeV4::default()
791 })
792 }
793
794 pub fn manual_sticky(target: String, children: Vec<String>) -> Self {
795 Self::single_entry_node(RoutingNodeV4 {
796 strategy: RoutingPolicyV4::ManualSticky,
797 target: Some(target),
798 children,
799 ..RoutingNodeV4::default()
800 })
801 }
802
803 pub fn tag_preferred(
804 children: Vec<String>,
805 prefer_tags: Vec<BTreeMap<String, String>>,
806 on_exhausted: RoutingExhaustedActionV4,
807 ) -> Self {
808 Self::single_entry_node(RoutingNodeV4 {
809 strategy: RoutingPolicyV4::TagPreferred,
810 children,
811 prefer_tags,
812 on_exhausted,
813 ..RoutingNodeV4::default()
814 })
815 }
816
817 pub fn single_entry_node(node: RoutingNodeV4) -> Self {
818 let entry = non_conflicting_default_route_entry(&node);
819 let mut out = Self {
820 routes: BTreeMap::from([(entry.clone(), node)]),
821 entry,
822 affinity_policy: default_routing_affinity_policy_v5(),
823 fallback_ttl_ms: None,
824 reprobe_preferred_after_ms: None,
825 policy: default_routing_policy_v4(),
826 order: Vec::new(),
827 target: None,
828 prefer_tags: Vec::new(),
829 chain: Vec::new(),
830 pools: BTreeMap::new(),
831 on_exhausted: default_routing_on_exhausted_v4(),
832 };
833 out.sync_compat_from_graph();
834 out
835 }
836
837 pub fn has_compat_authoring_fields(&self) -> bool {
838 self.policy != default_routing_policy_v4()
839 || !self.order.is_empty()
840 || self.target.is_some()
841 || !self.prefer_tags.is_empty()
842 || !self.chain.is_empty()
843 || !self.pools.is_empty()
844 || self.on_exhausted != default_routing_on_exhausted_v4()
845 }
846
847 pub fn entry_node(&self) -> Option<&RoutingNodeV4> {
848 self.routes.get(self.entry.as_str())
849 }
850
851 pub fn entry_node_mut(&mut self) -> Option<&mut RoutingNodeV4> {
852 self.routes.get_mut(self.entry.as_str())
853 }
854
855 pub fn sync_compat_from_graph(&mut self) {
856 let Some(node) = self.entry_node().cloned() else {
857 self.policy = default_routing_policy_v4();
858 self.order.clear();
859 self.target = None;
860 self.prefer_tags.clear();
861 self.chain.clear();
862 self.pools.clear();
863 self.on_exhausted = default_routing_on_exhausted_v4();
864 return;
865 };
866
867 self.policy = node.strategy;
868 self.target = node.target.clone();
869 self.prefer_tags = node.prefer_tags.clone();
870 self.on_exhausted = node.on_exhausted;
871 self.order = node.children.clone();
872 }
873
874 pub fn sync_graph_from_compat(&mut self) {
875 if !self.has_compat_authoring_fields() {
876 return;
877 }
878
879 if self.routes.is_empty() {
880 self.entry =
881 non_conflicting_default_route_entry_from_refs(&self.order, self.target.as_deref());
882 self.routes
883 .insert(self.entry.clone(), RoutingNodeV4::default());
884 }
885
886 let entry = self.entry.clone();
887 let node = self.routes.entry(entry).or_default();
888 node.strategy = self.policy;
889 node.target = self.target.clone();
890 node.prefer_tags = self.prefer_tags.clone();
891 node.on_exhausted = self.on_exhausted;
892 if !self.order.is_empty() {
893 node.children = self.order.clone();
894 }
895 }
896
897 pub fn route_node_names(&self) -> Vec<String> {
898 self.routes.keys().cloned().collect()
899 }
900
901 pub fn route_node_references(&self, target: &str) -> Vec<String> {
902 self.routes
903 .iter()
904 .filter_map(|(route_name, node)| {
905 if node.children.iter().any(|child| child == target)
906 || node.target.as_deref() == Some(target)
907 || node.then.as_deref() == Some(target)
908 || node.default_route.as_deref() == Some(target)
909 {
910 Some(route_name.clone())
911 } else {
912 None
913 }
914 })
915 .collect()
916 }
917
918 pub fn rename_route_node(&mut self, old: &str, new: String) -> Result<()> {
919 if old == new {
920 return Ok(());
921 }
922 if !self.routes.contains_key(old) {
923 anyhow::bail!("route node '{old}' does not exist");
924 }
925 if self.routes.contains_key(new.as_str()) {
926 anyhow::bail!("route node '{new}' already exists");
927 }
928
929 let Some(node) = self.routes.remove(old) else {
930 anyhow::bail!("route node '{old}' does not exist");
931 };
932 self.routes.insert(new.clone(), node);
933 if self.entry == old {
934 self.entry = new.clone();
935 }
936 for node in self.routes.values_mut() {
937 rewrite_route_node_refs(node, old, new.as_str());
938 }
939 self.sync_compat_from_graph();
940 Ok(())
941 }
942
943 pub fn delete_route_node(&mut self, name: &str) -> Result<()> {
944 if self.entry == name {
945 anyhow::bail!("entry route node '{name}' cannot be deleted");
946 }
947 if !self.routes.contains_key(name) {
948 anyhow::bail!("route node '{name}' does not exist");
949 }
950 let refs = self.route_node_references(name);
951 if !refs.is_empty() {
952 anyhow::bail!(
953 "route node '{name}' is still referenced by: {}",
954 refs.join(", ")
955 );
956 }
957 self.routes.remove(name);
958 self.sync_compat_from_graph();
959 Ok(())
960 }
961}
962
963fn default_routing_entry_v4() -> String {
964 "main".to_string()
965}
966
967fn non_conflicting_default_route_entry(node: &RoutingNodeV4) -> String {
968 non_conflicting_default_route_entry_from_refs(&node.children, node.target.as_deref())
969}
970
971fn non_conflicting_default_route_entry_from_refs(
972 children: &[String],
973 target: Option<&str>,
974) -> String {
975 let occupied = children
976 .iter()
977 .map(String::as_str)
978 .chain(target)
979 .collect::<BTreeSet<_>>();
980 let base = default_routing_entry_v4();
981 if !occupied.contains(base.as_str()) {
982 return base;
983 }
984
985 let mut candidate = format!("{base}_route");
986 let mut idx = 2usize;
987 while occupied.contains(candidate.as_str()) {
988 candidate = format!("{base}_route_{idx}");
989 idx += 1;
990 }
991 candidate
992}
993
994fn rewrite_route_node_refs(node: &mut RoutingNodeV4, old: &str, new: &str) {
995 for child in &mut node.children {
996 if child == old {
997 *child = new.to_string();
998 }
999 }
1000 if node.target.as_deref() == Some(old) {
1001 node.target = Some(new.to_string());
1002 }
1003 if node.then.as_deref() == Some(old) {
1004 node.then = Some(new.to_string());
1005 }
1006 if node.default_route.as_deref() == Some(old) {
1007 node.default_route = Some(new.to_string());
1008 }
1009}
1010
1011#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1012pub struct RoutingNodeV4 {
1013 #[serde(default = "default_routing_policy_v4")]
1014 pub strategy: RoutingPolicyV4,
1015 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1016 pub children: Vec<String>,
1017 #[serde(default, skip_serializing_if = "Option::is_none")]
1018 pub target: Option<String>,
1019 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1020 pub prefer_tags: Vec<BTreeMap<String, String>>,
1021 #[serde(default = "default_routing_on_exhausted_v4")]
1022 pub on_exhausted: RoutingExhaustedActionV4,
1023 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1024 pub metadata: BTreeMap<String, String>,
1025 #[serde(default, skip_serializing_if = "Option::is_none")]
1026 pub when: Option<RoutingConditionV4>,
1027 #[serde(default, skip_serializing_if = "Option::is_none")]
1028 pub then: Option<String>,
1029 #[serde(default, rename = "default", skip_serializing_if = "Option::is_none")]
1030 pub default_route: Option<String>,
1031}
1032
1033impl Default for RoutingNodeV4 {
1034 fn default() -> Self {
1035 Self {
1036 strategy: default_routing_policy_v4(),
1037 children: Vec::new(),
1038 target: None,
1039 prefer_tags: Vec::new(),
1040 on_exhausted: default_routing_on_exhausted_v4(),
1041 metadata: BTreeMap::new(),
1042 when: None,
1043 then: None,
1044 default_route: None,
1045 }
1046 }
1047}
1048
1049#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)]
1050pub struct RoutingConditionV4 {
1051 #[serde(default, skip_serializing_if = "Option::is_none")]
1052 pub model: Option<String>,
1053 #[serde(default, skip_serializing_if = "Option::is_none")]
1054 pub service_tier: Option<String>,
1055 #[serde(default, skip_serializing_if = "Option::is_none")]
1056 pub reasoning_effort: Option<String>,
1057 #[serde(default, skip_serializing_if = "Option::is_none")]
1058 pub path: Option<String>,
1059 #[serde(default, skip_serializing_if = "Option::is_none")]
1060 pub method: Option<String>,
1061 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1062 pub headers: BTreeMap<String, String>,
1063}
1064
1065impl RoutingConditionV4 {
1066 pub fn is_empty(&self) -> bool {
1067 self.model.is_none()
1068 && self.service_tier.is_none()
1069 && self.reasoning_effort.is_none()
1070 && self.path.is_none()
1071 && self.method.is_none()
1072 && self.headers.is_empty()
1073 }
1074}
1075
1076#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
1077#[serde(rename_all = "kebab-case")]
1078pub enum RoutingPolicyV4 {
1079 ManualSticky,
1080 OrderedFailover,
1081 TagPreferred,
1082 Conditional,
1083}
1084
1085fn default_routing_policy_v4() -> RoutingPolicyV4 {
1086 RoutingPolicyV4::OrderedFailover
1087}
1088
1089#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
1090#[serde(rename_all = "kebab-case")]
1091pub enum RoutingExhaustedActionV4 {
1092 Continue,
1093 Stop,
1094}
1095
1096fn default_routing_on_exhausted_v4() -> RoutingExhaustedActionV4 {
1097 RoutingExhaustedActionV4::Continue
1098}
1099
1100#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
1101#[serde(rename_all = "kebab-case")]
1102pub enum RoutingAffinityPolicyV5 {
1103 Off,
1104 PreferredGroup,
1105 FallbackSticky,
1106 Hard,
1107}
1108
1109fn default_routing_affinity_policy_v5() -> RoutingAffinityPolicyV5 {
1110 RoutingAffinityPolicyV5::FallbackSticky
1111}
1112
1113#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1114pub struct RoutingPoolV4 {
1115 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1116 pub providers: Vec<String>,
1117}
1118
1119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1120pub struct PersistedRoutingProviderRef {
1121 pub name: String,
1122 #[serde(default, skip_serializing_if = "Option::is_none")]
1123 pub alias: Option<String>,
1124 #[serde(default = "default_service_config_enabled")]
1125 pub enabled: bool,
1126 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1127 pub tags: BTreeMap<String, String>,
1128}
1129
1130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1131pub struct PersistedRoutingSpec {
1132 pub entry: String,
1133 #[serde(default = "default_routing_affinity_policy_v5")]
1134 pub affinity_policy: RoutingAffinityPolicyV5,
1135 #[serde(default, skip_serializing_if = "Option::is_none")]
1136 pub fallback_ttl_ms: Option<u64>,
1137 #[serde(default, skip_serializing_if = "Option::is_none")]
1138 pub reprobe_preferred_after_ms: Option<u64>,
1139 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1140 pub routes: BTreeMap<String, RoutingNodeV4>,
1141 #[serde(default = "default_routing_policy_v4")]
1142 pub policy: RoutingPolicyV4,
1143 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1144 pub order: Vec<String>,
1145 #[serde(default, skip_serializing_if = "Option::is_none")]
1146 pub target: Option<String>,
1147 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1148 pub prefer_tags: Vec<BTreeMap<String, String>>,
1149 #[serde(default = "default_routing_on_exhausted_v4")]
1150 pub on_exhausted: RoutingExhaustedActionV4,
1151 #[serde(default = "default_routing_policy_v4")]
1152 pub entry_strategy: RoutingPolicyV4,
1153 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1154 pub expanded_order: Vec<String>,
1155 #[serde(default, skip_serializing_if = "Option::is_none")]
1156 pub entry_target: Option<String>,
1157 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1158 pub providers: Vec<PersistedRoutingProviderRef>,
1159}
1160
1161fn is_default_upstream_auth(auth: &UpstreamAuth) -> bool {
1162 auth.auth_token.is_none()
1163 && auth.auth_token_env.is_none()
1164 && auth.api_key.is_none()
1165 && auth.api_key_env.is_none()
1166}
1167
1168#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1169pub struct ServiceViewV2 {
1170 #[serde(
1171 default,
1172 skip_serializing_if = "Option::is_none",
1173 rename = "active_station",
1174 alias = "active_group"
1175 )]
1176 pub active_group: Option<String>,
1177 #[serde(default, skip_serializing_if = "Option::is_none")]
1178 pub default_profile: Option<String>,
1179 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1180 pub profiles: BTreeMap<String, ServiceControlProfile>,
1181 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1182 pub providers: BTreeMap<String, ProviderConfigV2>,
1183 #[serde(
1184 default,
1185 skip_serializing_if = "BTreeMap::is_empty",
1186 rename = "stations",
1187 alias = "groups"
1188 )]
1189 pub groups: BTreeMap<String, GroupConfigV2>,
1190}
1191
1192#[derive(Debug, Clone, Serialize, Deserialize)]
1193pub struct ProviderConfigV2 {
1194 #[serde(default, skip_serializing_if = "Option::is_none")]
1195 pub alias: Option<String>,
1196 #[serde(default = "default_service_config_enabled")]
1197 pub enabled: bool,
1198 #[serde(default)]
1199 pub auth: UpstreamAuth,
1200 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1201 pub tags: BTreeMap<String, String>,
1202 #[serde(
1203 default,
1204 skip_serializing_if = "BTreeMap::is_empty",
1205 alias = "supportedModels"
1206 )]
1207 pub supported_models: BTreeMap<String, bool>,
1208 #[serde(
1209 default,
1210 skip_serializing_if = "BTreeMap::is_empty",
1211 alias = "modelMapping"
1212 )]
1213 pub model_mapping: BTreeMap<String, String>,
1214 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1215 pub endpoints: BTreeMap<String, ProviderEndpointV2>,
1216}
1217
1218impl Default for ProviderConfigV2 {
1219 fn default() -> Self {
1220 Self {
1221 alias: None,
1222 enabled: default_service_config_enabled(),
1223 auth: UpstreamAuth::default(),
1224 tags: BTreeMap::new(),
1225 supported_models: BTreeMap::new(),
1226 model_mapping: BTreeMap::new(),
1227 endpoints: BTreeMap::new(),
1228 }
1229 }
1230}
1231
1232#[derive(Debug, Clone, Serialize, Deserialize)]
1233pub struct ProviderEndpointV2 {
1234 pub base_url: String,
1235 #[serde(default = "default_service_config_enabled")]
1236 pub enabled: bool,
1237 #[serde(
1238 default = "default_provider_endpoint_priority",
1239 skip_serializing_if = "is_default_provider_endpoint_priority"
1240 )]
1241 pub priority: u32,
1242 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1243 pub tags: BTreeMap<String, String>,
1244 #[serde(
1245 default,
1246 skip_serializing_if = "BTreeMap::is_empty",
1247 alias = "supportedModels"
1248 )]
1249 pub supported_models: BTreeMap<String, bool>,
1250 #[serde(
1251 default,
1252 skip_serializing_if = "BTreeMap::is_empty",
1253 alias = "modelMapping"
1254 )]
1255 pub model_mapping: BTreeMap<String, String>,
1256}
1257
1258#[derive(Debug, Clone, Serialize, Deserialize)]
1259pub struct GroupConfigV2 {
1260 #[serde(default, skip_serializing_if = "Option::is_none")]
1261 pub alias: Option<String>,
1262 #[serde(default = "default_service_config_enabled")]
1263 pub enabled: bool,
1264 #[serde(default = "default_service_config_level")]
1265 pub level: u8,
1266 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1267 pub members: Vec<GroupMemberRefV2>,
1268}
1269
1270impl Default for GroupConfigV2 {
1271 fn default() -> Self {
1272 Self {
1273 alias: None,
1274 enabled: default_service_config_enabled(),
1275 level: default_service_config_level(),
1276 members: Vec::new(),
1277 }
1278 }
1279}
1280
1281#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1282pub struct GroupMemberRefV2 {
1283 pub provider: String,
1284 #[serde(default, skip_serializing_if = "Vec::is_empty", alias = "endpoints")]
1285 pub endpoint_names: Vec<String>,
1286 #[serde(default)]
1287 pub preferred: bool,
1288}
1289
1290#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1291pub struct PersistedStationProviderEndpointRef {
1292 pub name: String,
1293 pub base_url: String,
1294 #[serde(default = "default_service_config_enabled")]
1295 pub enabled: bool,
1296}
1297
1298#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1299pub struct PersistedStationProviderRef {
1300 pub name: String,
1301 #[serde(default, skip_serializing_if = "Option::is_none")]
1302 pub alias: Option<String>,
1303 #[serde(default = "default_service_config_enabled")]
1304 pub enabled: bool,
1305 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1306 pub endpoints: Vec<PersistedStationProviderEndpointRef>,
1307}
1308
1309#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1310pub struct PersistedStationSpec {
1311 pub name: String,
1312 #[serde(default, skip_serializing_if = "Option::is_none")]
1313 pub alias: Option<String>,
1314 #[serde(default = "default_service_config_enabled")]
1315 pub enabled: bool,
1316 #[serde(default = "default_service_config_level")]
1317 pub level: u8,
1318 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1319 pub members: Vec<GroupMemberRefV2>,
1320}
1321
1322#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1323pub struct PersistedStationsCatalog {
1324 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1325 pub stations: Vec<PersistedStationSpec>,
1326 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1327 pub providers: Vec<PersistedStationProviderRef>,
1328}
1329
1330#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1331pub struct PersistedProviderEndpointSpec {
1332 pub name: String,
1333 pub base_url: String,
1334 #[serde(default = "default_service_config_enabled")]
1335 pub enabled: bool,
1336 #[serde(
1337 default = "default_provider_endpoint_priority",
1338 skip_serializing_if = "is_default_provider_endpoint_priority"
1339 )]
1340 pub priority: u32,
1341 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1342 pub tags: BTreeMap<String, String>,
1343 #[serde(
1344 default,
1345 skip_serializing_if = "is_default_provider_concurrency_limits"
1346 )]
1347 pub limits: ProviderConcurrencyLimits,
1348}
1349
1350#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1351pub struct PersistedProviderSpec {
1352 pub name: String,
1353 #[serde(default, skip_serializing_if = "Option::is_none")]
1354 pub alias: Option<String>,
1355 #[serde(default = "default_service_config_enabled")]
1356 pub enabled: bool,
1357 #[serde(default, skip_serializing_if = "Option::is_none")]
1358 pub auth_token_env: Option<String>,
1359 #[serde(default, skip_serializing_if = "Option::is_none")]
1360 pub api_key_env: Option<String>,
1361 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1362 pub tags: BTreeMap<String, String>,
1363 #[serde(
1364 default,
1365 skip_serializing_if = "is_default_provider_concurrency_limits"
1366 )]
1367 pub limits: ProviderConcurrencyLimits,
1368 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1369 pub endpoints: Vec<PersistedProviderEndpointSpec>,
1370}
1371
1372#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1373pub struct PersistedProvidersCatalog {
1374 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1375 pub providers: Vec<PersistedProviderSpec>,
1376}
1377
1378#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1379pub struct UiConfig {
1380 #[serde(default)]
1384 pub language: Option<String>,
1385 #[serde(default, skip_serializing_if = "is_default_usage_forecast_config")]
1387 pub usage_forecast: UsageForecastConfig,
1388}
1389
1390pub fn proxy_home_dir() -> PathBuf {
1392 if let Ok(dir) = env::var("CODEX_HELPER_HOME") {
1393 let trimmed = dir.trim();
1394 if !trimmed.is_empty() {
1395 return PathBuf::from(trimmed);
1396 }
1397 }
1398
1399 #[cfg(test)]
1400 {
1401 static TEST_HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
1402 TEST_HOME
1403 .get_or_init(|| {
1404 let mut dir = std::env::temp_dir();
1405 let unique = format!(
1406 "codex-helper-test-{}-{}",
1407 std::process::id(),
1408 std::time::SystemTime::now()
1409 .duration_since(std::time::UNIX_EPOCH)
1410 .map(|d| d.as_nanos())
1411 .unwrap_or(0)
1412 );
1413 dir.push(unique);
1414 dir.push(".codex-helper");
1415 let _ = std::fs::create_dir_all(&dir);
1416 dir
1417 })
1418 .clone()
1419 }
1420
1421 #[cfg(not(test))]
1422 {
1423 dirs::home_dir()
1424 .unwrap_or_else(|| PathBuf::from("."))
1425 .join(".codex-helper")
1426 }
1427}
1428
1429pub fn codex_sessions_dir() -> PathBuf {
1431 codex_home().join("sessions")
1432}
1433
1434#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1436#[serde(rename_all = "lowercase")]
1437pub enum ServiceKind {
1438 Codex,
1439 Claude,
1440}
1441
1442#[cfg(test)]
1443mod tests;