1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::env;
3use std::fs as stdfs;
4use std::path::{Path, PathBuf};
5
6use crate::client_config::{codex_home, is_claude_absent_backup_sentinel};
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9use serde_json::Value as JsonValue;
10use tokio::fs;
11use toml::Value as TomlValue;
12use tracing::{info, warn};
13
14pub use crate::client_config::{
15 claude_settings_backup_path, claude_settings_path, codex_auth_path, codex_config_path,
16 codex_models_cache_path, codex_switch_state_path,
17};
18
19#[path = "config_storage.rs"]
20mod storage_impl;
21
22#[path = "config_bootstrap.rs"]
23mod bootstrap_impl;
24
25#[path = "config_auth_sync.rs"]
26mod auth_sync_impl;
27
28#[path = "config_retry.rs"]
29mod retry_impl;
30
31#[path = "config_profiles.rs"]
32mod profiles_impl;
33
34#[path = "config_routing.rs"]
35mod routing_impl;
36
37#[path = "config_v2.rs"]
38mod v2_impl;
39
40#[path = "config_v4.rs"]
41mod v4_impl;
42
43pub use auth_sync_impl::{
44 SyncCodexAuthFromCodexOptions, SyncCodexAuthFromCodexReport, sync_codex_auth_from_codex_cli,
45};
46pub(crate) use auth_sync_impl::{infer_env_key_from_auth_json, read_file_if_exists};
47pub use bootstrap_impl::{
48 import_codex_config_from_codex_cli, load_or_bootstrap_for_service,
49 load_or_bootstrap_for_service_with_v4_source, load_or_bootstrap_from_claude,
50 load_or_bootstrap_from_codex, overwrite_codex_config_from_codex_cli_in_place,
51 probe_codex_bootstrap_from_cli,
52};
53pub(crate) use profiles_impl::validate_service_profiles;
54pub use profiles_impl::{
55 ServiceControlProfile, resolve_service_profile, resolve_service_profile_from_catalog,
56 validate_profile_station_compatibility,
57};
58pub use retry_impl::{
59 ResolvedRetryConfig, ResolvedRetryLayerConfig, RetryConfig, RetryLayerConfig, RetryProfileName,
60 RetryStrategy,
61};
62pub use routing_impl::{RoutingCandidate, ServiceRoutingExplanation, explain_service_routing};
63pub use storage_impl::{
64 CodexClientPatchConfig, LoadedProxyConfig, codex_client_patch_config_from_config_file,
65 codex_client_patch_mode_from_config_file, codex_client_patch_preset_from_config_file,
66 config_file_path, init_config_toml, load_config, load_config_with_v4_source,
67 normalize_config_toml_authoring, normalize_config_toml_client_patch, save_config,
68 save_config_v2, save_config_v4,
69};
70pub use v2_impl::{
71 build_persisted_provider_catalog, build_persisted_station_catalog, compact_v2_config,
72 compile_v2_to_runtime, migrate_legacy_to_v2,
73};
74pub(crate) use v4_impl::compact_v4_config_for_write;
75pub use v4_impl::{
76 ConfigV4MigrationReport, collect_route_graph_affinity_migration_warnings,
77 compile_v4_to_runtime, compile_v4_to_v2, effective_v4_routing, migrate_legacy_to_v4,
78 migrate_legacy_to_v4_with_report, migrate_v2_to_v4, migrate_v2_to_v4_with_report,
79 resolved_v4_provider_order,
80};
81
82pub mod legacy {
83 pub use super::v4_impl::legacy::*;
84}
85
86#[cfg(test)]
87use bootstrap_impl::bootstrap_from_codex;
88
89pub mod storage {
90 pub use super::storage_impl::{
91 CodexClientPatchConfig, LoadedProxyConfig, codex_client_patch_config_from_config_file,
92 codex_client_patch_mode_from_config_file, codex_client_patch_preset_from_config_file,
93 config_file_path, init_config_toml, load_config, load_config_with_v4_source,
94 normalize_config_toml_authoring, normalize_config_toml_client_patch, save_config,
95 save_config_v2, save_config_v4,
96 };
97}
98
99pub mod bootstrap {
100 pub use super::bootstrap_impl::{
101 import_codex_config_from_codex_cli, load_or_bootstrap_for_service,
102 load_or_bootstrap_from_claude, load_or_bootstrap_from_codex,
103 overwrite_codex_config_from_codex_cli_in_place, probe_codex_bootstrap_from_cli,
104 };
105}
106
107pub mod auth_sync {
108 pub use super::auth_sync_impl::{
109 SyncCodexAuthFromCodexOptions, SyncCodexAuthFromCodexReport, sync_codex_auth_from_codex_cli,
110 };
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, Default)]
114pub struct UpstreamAuth {
115 #[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, PartialEq, Eq, Default)]
520#[serde(default)]
521pub struct RelayTargetConfig {
522 #[serde(skip_serializing_if = "Option::is_none")]
523 pub service: Option<ServiceKind>,
524 pub proxy_url: String,
525 #[serde(skip_serializing_if = "Option::is_none")]
526 pub admin_url: Option<String>,
527 #[serde(skip_serializing_if = "Option::is_none")]
528 pub admin_token_env: Option<String>,
529 #[serde(skip_serializing_if = "Option::is_none")]
530 pub client_preset: Option<crate::codex_integration::CodexPatchMode>,
531 #[serde(skip_serializing_if = "Option::is_none")]
532 pub responses_websocket: Option<bool>,
533}
534
535#[derive(Debug, Clone, Serialize, Deserialize, Default)]
536pub struct ProxyConfig {
537 #[serde(default)]
539 pub version: Option<u32>,
540 #[serde(default)]
542 pub codex: ServiceConfigManager,
543 #[serde(default)]
545 pub claude: ServiceConfigManager,
546 #[serde(default)]
548 pub retry: RetryConfig,
549 #[serde(default)]
551 pub notify: NotifyConfig,
552 #[serde(default)]
554 pub default_service: Option<ServiceKind>,
555 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
557 pub relay_targets: BTreeMap<String, RelayTargetConfig>,
558 #[serde(default)]
560 pub ui: UiConfig,
561}
562
563fn default_proxy_config_v2_version() -> u32 {
564 2
565}
566
567pub const LEGACY_ROUTE_GRAPH_CONFIG_VERSION: u32 = 4;
568pub const CURRENT_ROUTE_GRAPH_CONFIG_VERSION: u32 = 5;
569
570pub fn is_supported_route_graph_config_version(version: u32) -> bool {
571 matches!(
572 version,
573 LEGACY_ROUTE_GRAPH_CONFIG_VERSION | CURRENT_ROUTE_GRAPH_CONFIG_VERSION
574 )
575}
576
577fn default_proxy_config_v4_version() -> u32 {
578 CURRENT_ROUTE_GRAPH_CONFIG_VERSION
579}
580
581#[derive(Debug, Clone, Serialize, Deserialize)]
582pub struct ProxyConfigV2 {
583 #[serde(default = "default_proxy_config_v2_version")]
584 pub version: u32,
585 #[serde(default)]
586 pub codex: ServiceViewV2,
587 #[serde(default)]
588 pub claude: ServiceViewV2,
589 #[serde(default)]
590 pub retry: RetryConfig,
591 #[serde(default)]
592 pub notify: NotifyConfig,
593 #[serde(default)]
594 pub default_service: Option<ServiceKind>,
595 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
596 pub relay_targets: BTreeMap<String, RelayTargetConfig>,
597 #[serde(default)]
598 pub ui: UiConfig,
599}
600
601impl Default for ProxyConfigV2 {
602 fn default() -> Self {
603 Self {
604 version: default_proxy_config_v2_version(),
605 codex: ServiceViewV2::default(),
606 claude: ServiceViewV2::default(),
607 retry: RetryConfig::default(),
608 notify: NotifyConfig::default(),
609 default_service: None,
610 relay_targets: BTreeMap::new(),
611 ui: UiConfig::default(),
612 }
613 }
614}
615
616#[derive(Debug, Clone, Serialize, Deserialize)]
617pub struct ProxyConfigV4 {
618 #[serde(default = "default_proxy_config_v4_version")]
619 pub version: u32,
620 #[serde(default)]
621 pub codex: ServiceViewV4,
622 #[serde(default)]
623 pub claude: ServiceViewV4,
624 #[serde(default)]
625 pub retry: RetryConfig,
626 #[serde(default)]
627 pub notify: NotifyConfig,
628 #[serde(default)]
629 pub default_service: Option<ServiceKind>,
630 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
631 pub relay_targets: BTreeMap<String, RelayTargetConfig>,
632 #[serde(default)]
633 pub ui: UiConfig,
634}
635
636impl Default for ProxyConfigV4 {
637 fn default() -> Self {
638 Self {
639 version: default_proxy_config_v4_version(),
640 codex: ServiceViewV4::default(),
641 claude: ServiceViewV4::default(),
642 retry: RetryConfig::default(),
643 notify: NotifyConfig::default(),
644 default_service: None,
645 relay_targets: BTreeMap::new(),
646 ui: UiConfig::default(),
647 }
648 }
649}
650
651#[derive(Debug, Clone, Serialize, Deserialize, Default)]
652pub struct ServiceViewV4 {
653 #[serde(default, skip_serializing_if = "Option::is_none")]
654 pub default_profile: Option<String>,
655 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
656 pub profiles: BTreeMap<String, ServiceControlProfile>,
657 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
658 pub providers: BTreeMap<String, ProviderConfigV4>,
659 #[serde(default, skip_serializing_if = "Option::is_none")]
660 pub routing: Option<RoutingConfigV4>,
661}
662
663impl ServiceViewV4 {
664 pub fn ensure_routing_mut(&mut self) -> &mut RoutingConfigV4 {
665 self.routing.get_or_insert_with(RoutingConfigV4::default)
666 }
667
668 pub fn normalize_routing_authoring(&mut self) {
669 if let Some(routing) = self.routing.as_mut() {
670 routing.normalize_authoring();
671 }
672 }
673}
674
675#[derive(Debug, Clone, Serialize, Deserialize)]
676pub struct ProviderConfigV4 {
677 #[serde(default, skip_serializing_if = "Option::is_none")]
678 pub alias: Option<String>,
679 #[serde(
680 default = "default_service_config_enabled",
681 skip_serializing_if = "is_default_service_config_enabled"
682 )]
683 pub enabled: bool,
684 #[serde(default, skip_serializing_if = "Option::is_none")]
685 pub base_url: Option<String>,
686 #[serde(default, skip_serializing_if = "Option::is_none")]
687 pub continuity_domain: Option<String>,
688 #[serde(default, skip_serializing_if = "is_default_upstream_auth")]
689 pub auth: UpstreamAuth,
690 #[serde(default, flatten)]
691 pub inline_auth: UpstreamAuth,
692 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
693 pub tags: BTreeMap<String, String>,
694 #[serde(
695 default,
696 skip_serializing_if = "BTreeMap::is_empty",
697 alias = "supportedModels"
698 )]
699 pub supported_models: BTreeMap<String, bool>,
700 #[serde(
701 default,
702 skip_serializing_if = "BTreeMap::is_empty",
703 alias = "modelMapping"
704 )]
705 pub model_mapping: BTreeMap<String, String>,
706 #[serde(
707 default,
708 skip_serializing_if = "is_default_provider_concurrency_limits"
709 )]
710 pub limits: ProviderConcurrencyLimits,
711 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
712 pub endpoints: BTreeMap<String, ProviderEndpointV4>,
713}
714
715impl Default for ProviderConfigV4 {
716 fn default() -> Self {
717 Self {
718 alias: None,
719 enabled: default_service_config_enabled(),
720 base_url: None,
721 continuity_domain: None,
722 auth: UpstreamAuth::default(),
723 inline_auth: UpstreamAuth::default(),
724 tags: BTreeMap::new(),
725 supported_models: BTreeMap::new(),
726 model_mapping: BTreeMap::new(),
727 limits: ProviderConcurrencyLimits::default(),
728 endpoints: BTreeMap::new(),
729 }
730 }
731}
732
733#[derive(Debug, Clone, Serialize, Deserialize)]
734pub struct ProviderEndpointV4 {
735 pub base_url: String,
736 #[serde(default, skip_serializing_if = "Option::is_none")]
737 pub continuity_domain: Option<String>,
738 #[serde(
739 default = "default_service_config_enabled",
740 skip_serializing_if = "is_default_service_config_enabled"
741 )]
742 pub enabled: bool,
743 #[serde(
744 default = "default_provider_endpoint_priority",
745 skip_serializing_if = "is_default_provider_endpoint_priority"
746 )]
747 pub priority: u32,
748 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
749 pub tags: BTreeMap<String, String>,
750 #[serde(
751 default,
752 skip_serializing_if = "BTreeMap::is_empty",
753 alias = "supportedModels"
754 )]
755 pub supported_models: BTreeMap<String, bool>,
756 #[serde(
757 default,
758 skip_serializing_if = "BTreeMap::is_empty",
759 alias = "modelMapping"
760 )]
761 pub model_mapping: BTreeMap<String, String>,
762 #[serde(
763 default,
764 skip_serializing_if = "is_default_provider_concurrency_limits"
765 )]
766 pub limits: ProviderConcurrencyLimits,
767}
768
769#[derive(Debug, Clone, Serialize, Deserialize)]
770pub struct RoutingConfigV4 {
771 #[serde(default = "default_routing_entry_v4")]
772 pub entry: String,
773 #[serde(default = "default_routing_affinity_policy_v5")]
774 pub affinity_policy: RoutingAffinityPolicyV5,
775 #[serde(default, skip_serializing_if = "Option::is_none")]
776 pub fallback_ttl_ms: Option<u64>,
777 #[serde(default, skip_serializing_if = "Option::is_none")]
778 pub reprobe_preferred_after_ms: Option<u64>,
779 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
780 pub routes: BTreeMap<String, RoutingNodeV4>,
781 #[serde(skip, default = "default_routing_policy_v4")]
782 pub policy: RoutingPolicyV4,
783 #[serde(skip)]
784 pub order: Vec<String>,
785 #[serde(skip)]
786 pub target: Option<String>,
787 #[serde(skip)]
788 pub prefer_tags: Vec<BTreeMap<String, String>>,
789 #[serde(skip)]
790 pub chain: Vec<String>,
791 #[serde(skip)]
792 pub pools: BTreeMap<String, RoutingPoolV4>,
793 #[serde(skip, default = "default_routing_on_exhausted_v4")]
794 pub on_exhausted: RoutingExhaustedActionV4,
795}
796
797impl Default for RoutingConfigV4 {
798 fn default() -> Self {
799 Self {
800 entry: default_routing_entry_v4(),
801 affinity_policy: default_routing_affinity_policy_v5(),
802 fallback_ttl_ms: None,
803 reprobe_preferred_after_ms: None,
804 routes: BTreeMap::new(),
805 policy: default_routing_policy_v4(),
806 order: Vec::new(),
807 target: None,
808 prefer_tags: Vec::new(),
809 chain: Vec::new(),
810 pools: BTreeMap::new(),
811 on_exhausted: default_routing_on_exhausted_v4(),
812 }
813 }
814}
815
816impl ProxyConfigV4 {
817 pub fn normalize_routing_authoring(&mut self) {
818 self.codex.normalize_routing_authoring();
819 self.claude.normalize_routing_authoring();
820 }
821
822 pub fn sync_routing_compat_from_graph(&mut self) {
823 self.normalize_routing_authoring();
824 }
825}
826
827impl RoutingConfigV4 {
828 pub fn ordered_failover(children: Vec<String>) -> Self {
829 Self::single_entry_node(RoutingNodeV4 {
830 strategy: RoutingPolicyV4::OrderedFailover,
831 children,
832 ..RoutingNodeV4::default()
833 })
834 }
835
836 pub fn manual_sticky(target: String, children: Vec<String>) -> Self {
837 Self::single_entry_node(RoutingNodeV4 {
838 strategy: RoutingPolicyV4::ManualSticky,
839 target: Some(target),
840 children,
841 ..RoutingNodeV4::default()
842 })
843 }
844
845 pub fn tag_preferred(
846 children: Vec<String>,
847 prefer_tags: Vec<BTreeMap<String, String>>,
848 on_exhausted: RoutingExhaustedActionV4,
849 ) -> Self {
850 Self::single_entry_node(RoutingNodeV4 {
851 strategy: RoutingPolicyV4::TagPreferred,
852 children,
853 prefer_tags,
854 on_exhausted,
855 ..RoutingNodeV4::default()
856 })
857 }
858
859 pub fn single_entry_node(node: RoutingNodeV4) -> Self {
860 let entry = non_conflicting_default_route_entry(&node);
861 let mut out = Self {
862 routes: BTreeMap::from([(entry.clone(), node)]),
863 entry,
864 affinity_policy: default_routing_affinity_policy_v5(),
865 fallback_ttl_ms: None,
866 reprobe_preferred_after_ms: None,
867 policy: default_routing_policy_v4(),
868 order: Vec::new(),
869 target: None,
870 prefer_tags: Vec::new(),
871 chain: Vec::new(),
872 pools: BTreeMap::new(),
873 on_exhausted: default_routing_on_exhausted_v4(),
874 };
875 out.sync_compat_from_graph();
876 out
877 }
878
879 pub fn has_compat_authoring_fields(&self) -> bool {
880 self.policy != default_routing_policy_v4()
881 || !self.order.is_empty()
882 || self.target.is_some()
883 || !self.prefer_tags.is_empty()
884 || !self.chain.is_empty()
885 || !self.pools.is_empty()
886 || self.on_exhausted != default_routing_on_exhausted_v4()
887 }
888
889 pub fn entry_node(&self) -> Option<&RoutingNodeV4> {
890 self.routes.get(self.entry.as_str())
891 }
892
893 pub fn entry_node_mut(&mut self) -> Option<&mut RoutingNodeV4> {
894 self.routes.get_mut(self.entry.as_str())
895 }
896
897 pub fn normalize_authoring(&mut self) {
898 if self.routes.is_empty() {
899 self.sync_graph_from_compat();
900 }
901 self.sync_compat_from_graph();
902 }
903
904 pub fn ensure_graph_from_compat(&mut self) {
905 if self.routes.is_empty() {
906 self.sync_graph_from_compat();
907 }
908 }
909
910 pub fn ensure_entry_node_mut(&mut self) -> &mut RoutingNodeV4 {
911 self.ensure_graph_from_compat();
912 let entry = self.entry.clone();
913 self.routes.entry(entry).or_default()
914 }
915
916 pub fn set_entry_routing(
917 &mut self,
918 policy: RoutingPolicyV4,
919 target: Option<String>,
920 children: Vec<String>,
921 prefer_tags: Vec<BTreeMap<String, String>>,
922 on_exhausted: RoutingExhaustedActionV4,
923 ) {
924 let node = self.ensure_entry_node_mut();
925 node.strategy = policy;
926 node.children = children;
927 node.target = target;
928 node.prefer_tags = prefer_tags;
929 node.on_exhausted = on_exhausted;
930 if !matches!(node.strategy, RoutingPolicyV4::ManualSticky) {
931 node.target = None;
932 }
933 if !matches!(node.strategy, RoutingPolicyV4::TagPreferred) {
934 node.prefer_tags.clear();
935 }
936 self.sync_compat_from_graph();
937 }
938
939 pub fn clear_entry_target(&mut self, children: Vec<String>) {
940 self.set_entry_routing(
941 RoutingPolicyV4::OrderedFailover,
942 None,
943 children,
944 Vec::new(),
945 RoutingExhaustedActionV4::Continue,
946 );
947 }
948
949 pub fn ensure_entry_order_contains(&mut self, provider_name: &str) {
950 let node = self.ensure_entry_node_mut();
951 if !node.children.iter().any(|name| name == provider_name) {
952 node.children.push(provider_name.to_string());
953 }
954 self.sync_compat_from_graph();
955 }
956
957 pub fn clear_manual_target_for(&mut self, provider_name: &str) -> bool {
958 let node = self.ensure_entry_node_mut();
959 let should_clear = matches!(node.strategy, RoutingPolicyV4::ManualSticky)
960 && node.target.as_deref() == Some(provider_name);
961 if !should_clear {
962 return false;
963 }
964 node.strategy = RoutingPolicyV4::OrderedFailover;
965 node.target = None;
966 node.prefer_tags.clear();
967 node.on_exhausted = RoutingExhaustedActionV4::Continue;
968 self.sync_compat_from_graph();
969 true
970 }
971
972 pub fn remove_provider_references(&mut self, provider_name: &str) {
973 self.ensure_graph_from_compat();
974 for node in self.routes.values_mut() {
975 node.children.retain(|name| name != provider_name);
976 if node.target.as_deref() == Some(provider_name) {
977 node.target = None;
978 if matches!(node.strategy, RoutingPolicyV4::ManualSticky) {
979 node.strategy = RoutingPolicyV4::OrderedFailover;
980 }
981 }
982 }
983 self.sync_compat_from_graph();
984 }
985
986 pub fn sync_compat_from_graph(&mut self) {
987 let Some(node) = self.entry_node().cloned() else {
988 self.policy = default_routing_policy_v4();
989 self.order.clear();
990 self.target = None;
991 self.prefer_tags.clear();
992 self.chain.clear();
993 self.pools.clear();
994 self.on_exhausted = default_routing_on_exhausted_v4();
995 return;
996 };
997
998 self.policy = node.strategy;
999 self.target = node.target.clone();
1000 self.prefer_tags = node.prefer_tags.clone();
1001 self.on_exhausted = node.on_exhausted;
1002 self.order = node.children.clone();
1003 }
1004
1005 pub fn sync_graph_from_compat(&mut self) {
1006 if !self.has_compat_authoring_fields() {
1007 return;
1008 }
1009
1010 if self.routes.is_empty() {
1011 self.entry =
1012 non_conflicting_default_route_entry_from_refs(&self.order, self.target.as_deref());
1013 self.routes
1014 .insert(self.entry.clone(), RoutingNodeV4::default());
1015 }
1016
1017 let entry = self.entry.clone();
1018 let node = self.routes.entry(entry).or_default();
1019 node.strategy = self.policy;
1020 node.target = self.target.clone();
1021 node.prefer_tags = self.prefer_tags.clone();
1022 node.on_exhausted = self.on_exhausted;
1023 if !self.order.is_empty() {
1024 node.children = self.order.clone();
1025 }
1026 }
1027
1028 pub fn route_node_names(&self) -> Vec<String> {
1029 self.routes.keys().cloned().collect()
1030 }
1031
1032 pub fn route_node_references(&self, target: &str) -> Vec<String> {
1033 self.routes
1034 .iter()
1035 .filter_map(|(route_name, node)| {
1036 if node.children.iter().any(|child| child == target)
1037 || node.target.as_deref() == Some(target)
1038 || node.then.as_deref() == Some(target)
1039 || node.default_route.as_deref() == Some(target)
1040 {
1041 Some(route_name.clone())
1042 } else {
1043 None
1044 }
1045 })
1046 .collect()
1047 }
1048
1049 pub fn rename_route_node(&mut self, old: &str, new: String) -> Result<()> {
1050 if old == new {
1051 return Ok(());
1052 }
1053 if !self.routes.contains_key(old) {
1054 anyhow::bail!("route node '{old}' does not exist");
1055 }
1056 if self.routes.contains_key(new.as_str()) {
1057 anyhow::bail!("route node '{new}' already exists");
1058 }
1059
1060 let Some(node) = self.routes.remove(old) else {
1061 anyhow::bail!("route node '{old}' does not exist");
1062 };
1063 self.routes.insert(new.clone(), node);
1064 if self.entry == old {
1065 self.entry = new.clone();
1066 }
1067 for node in self.routes.values_mut() {
1068 rewrite_route_node_refs(node, old, new.as_str());
1069 }
1070 self.sync_compat_from_graph();
1071 Ok(())
1072 }
1073
1074 pub fn delete_route_node(&mut self, name: &str) -> Result<()> {
1075 if self.entry == name {
1076 anyhow::bail!("entry route node '{name}' cannot be deleted");
1077 }
1078 if !self.routes.contains_key(name) {
1079 anyhow::bail!("route node '{name}' does not exist");
1080 }
1081 let refs = self.route_node_references(name);
1082 if !refs.is_empty() {
1083 anyhow::bail!(
1084 "route node '{name}' is still referenced by: {}",
1085 refs.join(", ")
1086 );
1087 }
1088 self.routes.remove(name);
1089 self.sync_compat_from_graph();
1090 Ok(())
1091 }
1092}
1093
1094fn default_routing_entry_v4() -> String {
1095 "main".to_string()
1096}
1097
1098fn non_conflicting_default_route_entry(node: &RoutingNodeV4) -> String {
1099 non_conflicting_default_route_entry_from_refs(&node.children, node.target.as_deref())
1100}
1101
1102fn non_conflicting_default_route_entry_from_refs(
1103 children: &[String],
1104 target: Option<&str>,
1105) -> String {
1106 let occupied = children
1107 .iter()
1108 .map(String::as_str)
1109 .chain(target)
1110 .collect::<BTreeSet<_>>();
1111 let base = default_routing_entry_v4();
1112 if !occupied.contains(base.as_str()) {
1113 return base;
1114 }
1115
1116 let mut candidate = format!("{base}_route");
1117 let mut idx = 2usize;
1118 while occupied.contains(candidate.as_str()) {
1119 candidate = format!("{base}_route_{idx}");
1120 idx += 1;
1121 }
1122 candidate
1123}
1124
1125fn rewrite_route_node_refs(node: &mut RoutingNodeV4, old: &str, new: &str) {
1126 for child in &mut node.children {
1127 if child == old {
1128 *child = new.to_string();
1129 }
1130 }
1131 if node.target.as_deref() == Some(old) {
1132 node.target = Some(new.to_string());
1133 }
1134 if node.then.as_deref() == Some(old) {
1135 node.then = Some(new.to_string());
1136 }
1137 if node.default_route.as_deref() == Some(old) {
1138 node.default_route = Some(new.to_string());
1139 }
1140}
1141
1142#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1143pub struct RoutingNodeV4 {
1144 #[serde(default = "default_routing_policy_v4")]
1145 pub strategy: RoutingPolicyV4,
1146 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1147 pub children: Vec<String>,
1148 #[serde(default, skip_serializing_if = "Option::is_none")]
1149 pub target: Option<String>,
1150 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1151 pub prefer_tags: Vec<BTreeMap<String, String>>,
1152 #[serde(default = "default_routing_on_exhausted_v4")]
1153 pub on_exhausted: RoutingExhaustedActionV4,
1154 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1155 pub metadata: BTreeMap<String, String>,
1156 #[serde(default, skip_serializing_if = "Option::is_none")]
1157 pub when: Option<RoutingConditionV4>,
1158 #[serde(default, skip_serializing_if = "Option::is_none")]
1159 pub then: Option<String>,
1160 #[serde(default, rename = "default", skip_serializing_if = "Option::is_none")]
1161 pub default_route: Option<String>,
1162}
1163
1164impl Default for RoutingNodeV4 {
1165 fn default() -> Self {
1166 Self {
1167 strategy: default_routing_policy_v4(),
1168 children: Vec::new(),
1169 target: None,
1170 prefer_tags: Vec::new(),
1171 on_exhausted: default_routing_on_exhausted_v4(),
1172 metadata: BTreeMap::new(),
1173 when: None,
1174 then: None,
1175 default_route: None,
1176 }
1177 }
1178}
1179
1180#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)]
1181pub struct RoutingConditionV4 {
1182 #[serde(default, skip_serializing_if = "Option::is_none")]
1183 pub model: Option<String>,
1184 #[serde(default, skip_serializing_if = "Option::is_none")]
1185 pub service_tier: Option<String>,
1186 #[serde(default, skip_serializing_if = "Option::is_none")]
1187 pub reasoning_effort: Option<String>,
1188 #[serde(default, skip_serializing_if = "Option::is_none")]
1189 pub path: Option<String>,
1190 #[serde(default, skip_serializing_if = "Option::is_none")]
1191 pub method: Option<String>,
1192 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1193 pub headers: BTreeMap<String, String>,
1194}
1195
1196impl RoutingConditionV4 {
1197 pub fn is_empty(&self) -> bool {
1198 self.model.is_none()
1199 && self.service_tier.is_none()
1200 && self.reasoning_effort.is_none()
1201 && self.path.is_none()
1202 && self.method.is_none()
1203 && self.headers.is_empty()
1204 }
1205}
1206
1207#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
1208#[serde(rename_all = "kebab-case")]
1209pub enum RoutingPolicyV4 {
1210 ManualSticky,
1211 OrderedFailover,
1212 TagPreferred,
1213 Conditional,
1214}
1215
1216fn default_routing_policy_v4() -> RoutingPolicyV4 {
1217 RoutingPolicyV4::OrderedFailover
1218}
1219
1220#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
1221#[serde(rename_all = "kebab-case")]
1222pub enum RoutingExhaustedActionV4 {
1223 Continue,
1224 Stop,
1225}
1226
1227fn default_routing_on_exhausted_v4() -> RoutingExhaustedActionV4 {
1228 RoutingExhaustedActionV4::Continue
1229}
1230
1231#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
1232#[serde(rename_all = "kebab-case")]
1233pub enum RoutingAffinityPolicyV5 {
1234 Off,
1235 PreferredGroup,
1236 FallbackSticky,
1237 Hard,
1238}
1239
1240fn default_routing_affinity_policy_v5() -> RoutingAffinityPolicyV5 {
1241 RoutingAffinityPolicyV5::FallbackSticky
1242}
1243
1244#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1245pub struct RoutingPoolV4 {
1246 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1247 pub providers: Vec<String>,
1248}
1249
1250#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1251pub struct PersistedRoutingProviderRef {
1252 pub name: String,
1253 #[serde(default, skip_serializing_if = "Option::is_none")]
1254 pub alias: Option<String>,
1255 #[serde(default = "default_service_config_enabled")]
1256 pub enabled: bool,
1257 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1258 pub tags: BTreeMap<String, String>,
1259}
1260
1261#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1262pub struct PersistedRoutingSpec {
1263 pub entry: String,
1264 #[serde(default = "default_routing_affinity_policy_v5")]
1265 pub affinity_policy: RoutingAffinityPolicyV5,
1266 #[serde(default, skip_serializing_if = "Option::is_none")]
1267 pub fallback_ttl_ms: Option<u64>,
1268 #[serde(default, skip_serializing_if = "Option::is_none")]
1269 pub reprobe_preferred_after_ms: Option<u64>,
1270 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1271 pub routes: BTreeMap<String, RoutingNodeV4>,
1272 #[serde(default = "default_routing_policy_v4")]
1273 pub policy: RoutingPolicyV4,
1274 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1275 pub order: Vec<String>,
1276 #[serde(default, skip_serializing_if = "Option::is_none")]
1277 pub target: Option<String>,
1278 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1279 pub prefer_tags: Vec<BTreeMap<String, String>>,
1280 #[serde(default = "default_routing_on_exhausted_v4")]
1281 pub on_exhausted: RoutingExhaustedActionV4,
1282 #[serde(default = "default_routing_policy_v4")]
1283 pub entry_strategy: RoutingPolicyV4,
1284 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1285 pub expanded_order: Vec<String>,
1286 #[serde(default, skip_serializing_if = "Option::is_none")]
1287 pub entry_target: Option<String>,
1288 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1289 pub providers: Vec<PersistedRoutingProviderRef>,
1290}
1291
1292fn is_default_upstream_auth(auth: &UpstreamAuth) -> bool {
1293 auth.auth_token.is_none()
1294 && auth.auth_token_env.is_none()
1295 && auth.api_key.is_none()
1296 && auth.api_key_env.is_none()
1297}
1298
1299#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1300pub struct ServiceViewV2 {
1301 #[serde(
1302 default,
1303 skip_serializing_if = "Option::is_none",
1304 rename = "active_station",
1305 alias = "active_group"
1306 )]
1307 pub active_group: Option<String>,
1308 #[serde(default, skip_serializing_if = "Option::is_none")]
1309 pub default_profile: Option<String>,
1310 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1311 pub profiles: BTreeMap<String, ServiceControlProfile>,
1312 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1313 pub providers: BTreeMap<String, ProviderConfigV2>,
1314 #[serde(
1315 default,
1316 skip_serializing_if = "BTreeMap::is_empty",
1317 rename = "stations",
1318 alias = "groups"
1319 )]
1320 pub groups: BTreeMap<String, GroupConfigV2>,
1321}
1322
1323#[derive(Debug, Clone, Serialize, Deserialize)]
1324pub struct ProviderConfigV2 {
1325 #[serde(default, skip_serializing_if = "Option::is_none")]
1326 pub alias: Option<String>,
1327 #[serde(default = "default_service_config_enabled")]
1328 pub enabled: bool,
1329 #[serde(default)]
1330 pub auth: UpstreamAuth,
1331 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1332 pub tags: BTreeMap<String, String>,
1333 #[serde(
1334 default,
1335 skip_serializing_if = "BTreeMap::is_empty",
1336 alias = "supportedModels"
1337 )]
1338 pub supported_models: BTreeMap<String, bool>,
1339 #[serde(
1340 default,
1341 skip_serializing_if = "BTreeMap::is_empty",
1342 alias = "modelMapping"
1343 )]
1344 pub model_mapping: BTreeMap<String, String>,
1345 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1346 pub endpoints: BTreeMap<String, ProviderEndpointV2>,
1347}
1348
1349impl Default for ProviderConfigV2 {
1350 fn default() -> Self {
1351 Self {
1352 alias: None,
1353 enabled: default_service_config_enabled(),
1354 auth: UpstreamAuth::default(),
1355 tags: BTreeMap::new(),
1356 supported_models: BTreeMap::new(),
1357 model_mapping: BTreeMap::new(),
1358 endpoints: BTreeMap::new(),
1359 }
1360 }
1361}
1362
1363#[derive(Debug, Clone, Serialize, Deserialize)]
1364pub struct ProviderEndpointV2 {
1365 pub base_url: String,
1366 #[serde(default = "default_service_config_enabled")]
1367 pub enabled: bool,
1368 #[serde(
1369 default = "default_provider_endpoint_priority",
1370 skip_serializing_if = "is_default_provider_endpoint_priority"
1371 )]
1372 pub priority: u32,
1373 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1374 pub tags: BTreeMap<String, String>,
1375 #[serde(
1376 default,
1377 skip_serializing_if = "BTreeMap::is_empty",
1378 alias = "supportedModels"
1379 )]
1380 pub supported_models: BTreeMap<String, bool>,
1381 #[serde(
1382 default,
1383 skip_serializing_if = "BTreeMap::is_empty",
1384 alias = "modelMapping"
1385 )]
1386 pub model_mapping: BTreeMap<String, String>,
1387}
1388
1389#[derive(Debug, Clone, Serialize, Deserialize)]
1390pub struct GroupConfigV2 {
1391 #[serde(default, skip_serializing_if = "Option::is_none")]
1392 pub alias: Option<String>,
1393 #[serde(default = "default_service_config_enabled")]
1394 pub enabled: bool,
1395 #[serde(default = "default_service_config_level")]
1396 pub level: u8,
1397 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1398 pub members: Vec<GroupMemberRefV2>,
1399}
1400
1401impl Default for GroupConfigV2 {
1402 fn default() -> Self {
1403 Self {
1404 alias: None,
1405 enabled: default_service_config_enabled(),
1406 level: default_service_config_level(),
1407 members: Vec::new(),
1408 }
1409 }
1410}
1411
1412#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1413pub struct GroupMemberRefV2 {
1414 pub provider: String,
1415 #[serde(default, skip_serializing_if = "Vec::is_empty", alias = "endpoints")]
1416 pub endpoint_names: Vec<String>,
1417 #[serde(default)]
1418 pub preferred: bool,
1419}
1420
1421#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1422pub struct PersistedStationProviderEndpointRef {
1423 pub name: String,
1424 pub base_url: String,
1425 #[serde(default = "default_service_config_enabled")]
1426 pub enabled: bool,
1427}
1428
1429#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1430pub struct PersistedStationProviderRef {
1431 pub name: String,
1432 #[serde(default, skip_serializing_if = "Option::is_none")]
1433 pub alias: Option<String>,
1434 #[serde(default = "default_service_config_enabled")]
1435 pub enabled: bool,
1436 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1437 pub endpoints: Vec<PersistedStationProviderEndpointRef>,
1438}
1439
1440#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1441pub struct PersistedStationSpec {
1442 pub name: String,
1443 #[serde(default, skip_serializing_if = "Option::is_none")]
1444 pub alias: Option<String>,
1445 #[serde(default = "default_service_config_enabled")]
1446 pub enabled: bool,
1447 #[serde(default = "default_service_config_level")]
1448 pub level: u8,
1449 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1450 pub members: Vec<GroupMemberRefV2>,
1451}
1452
1453#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1454pub struct PersistedStationsCatalog {
1455 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1456 pub stations: Vec<PersistedStationSpec>,
1457 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1458 pub providers: Vec<PersistedStationProviderRef>,
1459}
1460
1461#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1462pub struct PersistedProviderEndpointSpec {
1463 pub name: String,
1464 pub base_url: String,
1465 #[serde(default = "default_service_config_enabled")]
1466 pub enabled: bool,
1467 #[serde(
1468 default = "default_provider_endpoint_priority",
1469 skip_serializing_if = "is_default_provider_endpoint_priority"
1470 )]
1471 pub priority: u32,
1472 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1473 pub tags: BTreeMap<String, String>,
1474 #[serde(
1475 default,
1476 skip_serializing_if = "is_default_provider_concurrency_limits"
1477 )]
1478 pub limits: ProviderConcurrencyLimits,
1479}
1480
1481#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1482pub struct PersistedProviderSpec {
1483 pub name: String,
1484 #[serde(default, skip_serializing_if = "Option::is_none")]
1485 pub alias: Option<String>,
1486 #[serde(default = "default_service_config_enabled")]
1487 pub enabled: bool,
1488 #[serde(default, skip_serializing_if = "Option::is_none")]
1489 pub auth_token_env: Option<String>,
1490 #[serde(default, skip_serializing_if = "Option::is_none")]
1491 pub api_key_env: Option<String>,
1492 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1493 pub tags: BTreeMap<String, String>,
1494 #[serde(
1495 default,
1496 skip_serializing_if = "is_default_provider_concurrency_limits"
1497 )]
1498 pub limits: ProviderConcurrencyLimits,
1499 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1500 pub endpoints: Vec<PersistedProviderEndpointSpec>,
1501}
1502
1503#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1504pub struct PersistedProvidersCatalog {
1505 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1506 pub providers: Vec<PersistedProviderSpec>,
1507}
1508
1509#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1510pub struct UiConfig {
1511 #[serde(default)]
1515 pub language: Option<String>,
1516 #[serde(default, skip_serializing_if = "is_default_usage_forecast_config")]
1518 pub usage_forecast: UsageForecastConfig,
1519}
1520
1521pub fn proxy_home_dir() -> PathBuf {
1523 if let Ok(dir) = env::var("CODEX_HELPER_HOME") {
1524 let trimmed = dir.trim();
1525 if !trimmed.is_empty() {
1526 return PathBuf::from(trimmed);
1527 }
1528 }
1529
1530 #[cfg(test)]
1531 {
1532 static TEST_HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
1533 TEST_HOME
1534 .get_or_init(|| {
1535 let mut dir = std::env::temp_dir();
1536 let unique = format!(
1537 "codex-helper-test-{}-{}",
1538 std::process::id(),
1539 std::time::SystemTime::now()
1540 .duration_since(std::time::UNIX_EPOCH)
1541 .map(|d| d.as_nanos())
1542 .unwrap_or(0)
1543 );
1544 dir.push(unique);
1545 dir.push(".codex-helper");
1546 let _ = std::fs::create_dir_all(&dir);
1547 dir
1548 })
1549 .clone()
1550 }
1551
1552 #[cfg(not(test))]
1553 {
1554 dirs::home_dir()
1555 .unwrap_or_else(|| PathBuf::from("."))
1556 .join(".codex-helper")
1557 }
1558}
1559
1560pub fn codex_sessions_dir() -> PathBuf {
1562 codex_home().join("sessions")
1563}
1564
1565#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1567#[serde(rename_all = "lowercase")]
1568pub enum ServiceKind {
1569 Codex,
1570 Claude,
1571}
1572
1573#[cfg(test)]
1574mod tests;