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