1use indexmap::IndexMap;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::collections::HashMap;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Provider {
11 pub id: String,
12 pub name: String,
13 #[serde(rename = "settingsConfig")]
14 pub settings_config: Value,
15 #[serde(skip_serializing_if = "Option::is_none")]
16 #[serde(rename = "websiteUrl")]
17 pub website_url: Option<String>,
18 #[serde(skip_serializing_if = "Option::is_none")]
19 pub category: Option<String>,
20 #[serde(skip_serializing_if = "Option::is_none")]
21 #[serde(rename = "createdAt")]
22 pub created_at: Option<i64>,
23 #[serde(skip_serializing_if = "Option::is_none")]
24 #[serde(rename = "sortIndex")]
25 pub sort_index: Option<usize>,
26 #[serde(skip_serializing_if = "Option::is_none")]
28 pub notes: Option<String>,
29 #[serde(skip_serializing_if = "Option::is_none")]
31 pub meta: Option<ProviderMeta>,
32 #[serde(skip_serializing_if = "Option::is_none")]
34 pub icon: Option<String>,
35 #[serde(skip_serializing_if = "Option::is_none")]
37 #[serde(rename = "iconColor")]
38 pub icon_color: Option<String>,
39 #[serde(default)]
41 #[serde(rename = "inFailoverQueue")]
42 pub in_failover_queue: bool,
43}
44
45impl Provider {
46 pub fn with_id(
48 id: String,
49 name: String,
50 settings_config: Value,
51 website_url: Option<String>,
52 ) -> Self {
53 Self {
54 id,
55 name,
56 settings_config,
57 website_url,
58 category: None,
59 created_at: None,
60 sort_index: None,
61 notes: None,
62 meta: None,
63 icon: None,
64 icon_color: None,
65 in_failover_queue: false,
66 }
67 }
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, Default)]
72pub struct ProviderManager {
73 pub providers: IndexMap<String, Provider>,
74 pub current: String,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct UsageScript {
80 pub enabled: bool,
81 pub language: String,
82 pub code: String,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub timeout: Option<u64>,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 #[serde(rename = "apiKey")]
88 pub api_key: Option<String>,
89 #[serde(skip_serializing_if = "Option::is_none")]
91 #[serde(rename = "baseUrl")]
92 pub base_url: Option<String>,
93 #[serde(skip_serializing_if = "Option::is_none")]
95 #[serde(rename = "accessToken")]
96 pub access_token: Option<String>,
97 #[serde(skip_serializing_if = "Option::is_none")]
99 #[serde(rename = "userId")]
100 pub user_id: Option<String>,
101 #[serde(skip_serializing_if = "Option::is_none")]
103 #[serde(rename = "templateType")]
104 pub template_type: Option<String>,
105 #[serde(skip_serializing_if = "Option::is_none")]
107 #[serde(rename = "autoQueryInterval")]
108 pub auto_query_interval: Option<u64>,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct UsageData {
114 #[serde(skip_serializing_if = "Option::is_none")]
115 #[serde(rename = "planName")]
116 pub plan_name: Option<String>,
117 #[serde(skip_serializing_if = "Option::is_none")]
118 pub extra: Option<String>,
119 #[serde(skip_serializing_if = "Option::is_none")]
120 #[serde(rename = "isValid")]
121 pub is_valid: Option<bool>,
122 #[serde(skip_serializing_if = "Option::is_none")]
123 #[serde(rename = "invalidMessage")]
124 pub invalid_message: Option<String>,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub total: Option<f64>,
127 #[serde(skip_serializing_if = "Option::is_none")]
128 pub used: Option<f64>,
129 #[serde(skip_serializing_if = "Option::is_none")]
130 pub remaining: Option<f64>,
131 #[serde(skip_serializing_if = "Option::is_none")]
132 pub unit: Option<String>,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct UsageResult {
138 pub success: bool,
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub data: Option<Vec<UsageData>>, #[serde(skip_serializing_if = "Option::is_none")]
142 pub error: Option<String>,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize, Default)]
147pub struct ProviderTestConfig {
148 #[serde(default)]
150 pub enabled: bool,
151 #[serde(rename = "testModel", skip_serializing_if = "Option::is_none")]
153 pub test_model: Option<String>,
154 #[serde(rename = "timeoutSecs", skip_serializing_if = "Option::is_none")]
156 pub timeout_secs: Option<u64>,
157 #[serde(rename = "testPrompt", skip_serializing_if = "Option::is_none")]
159 pub test_prompt: Option<String>,
160 #[serde(
162 rename = "degradedThresholdMs",
163 skip_serializing_if = "Option::is_none"
164 )]
165 pub degraded_threshold_ms: Option<u64>,
166 #[serde(rename = "maxRetries", skip_serializing_if = "Option::is_none")]
168 pub max_retries: Option<u32>,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize, Default)]
173pub struct ProviderProxyConfig {
174 #[serde(default)]
176 pub enabled: bool,
177 #[serde(rename = "proxyType", skip_serializing_if = "Option::is_none")]
179 pub proxy_type: Option<String>,
180 #[serde(rename = "proxyHost", skip_serializing_if = "Option::is_none")]
182 pub proxy_host: Option<String>,
183 #[serde(rename = "proxyPort", skip_serializing_if = "Option::is_none")]
185 pub proxy_port: Option<u16>,
186 #[serde(rename = "proxyUsername", skip_serializing_if = "Option::is_none")]
188 pub proxy_username: Option<String>,
189 #[serde(rename = "proxyPassword", skip_serializing_if = "Option::is_none")]
191 pub proxy_password: Option<String>,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
196#[serde(rename_all = "snake_case")]
197pub enum AuthBindingSource {
198 #[default]
200 ProviderConfig,
201 ManagedAccount,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize, Default)]
207pub struct AuthBinding {
208 #[serde(default)]
210 pub source: AuthBindingSource,
211 #[serde(rename = "authProvider", skip_serializing_if = "Option::is_none")]
213 pub auth_provider: Option<String>,
214 #[serde(rename = "accountId", skip_serializing_if = "Option::is_none")]
216 pub account_id: Option<String>,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize, Default)]
221pub struct ProviderMeta {
222 #[serde(
224 rename = "commonConfigEnabled",
225 alias = "applyCommonConfig",
226 skip_serializing_if = "Option::is_none"
227 )]
228 pub apply_common_config: Option<bool>,
229 #[serde(rename = "codexOfficial", skip_serializing_if = "Option::is_none")]
231 pub codex_official: Option<bool>,
232 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
234 pub custom_endpoints: HashMap<String, crate::settings::CustomEndpoint>,
235 #[serde(skip_serializing_if = "Option::is_none")]
237 pub usage_script: Option<UsageScript>,
238 #[serde(rename = "endpointAutoSelect", skip_serializing_if = "Option::is_none")]
240 pub endpoint_auto_select: Option<bool>,
241 #[serde(rename = "isPartner", skip_serializing_if = "Option::is_none")]
243 pub is_partner: Option<bool>,
244 #[serde(
246 rename = "partnerPromotionKey",
247 skip_serializing_if = "Option::is_none"
248 )]
249 pub partner_promotion_key: Option<String>,
250 #[serde(rename = "costMultiplier", skip_serializing_if = "Option::is_none")]
252 pub cost_multiplier: Option<String>,
253 #[serde(rename = "pricingModelSource", skip_serializing_if = "Option::is_none")]
255 pub pricing_model_source: Option<String>,
256 #[serde(rename = "limitDailyUsd", skip_serializing_if = "Option::is_none")]
258 pub limit_daily_usd: Option<String>,
259 #[serde(rename = "limitMonthlyUsd", skip_serializing_if = "Option::is_none")]
261 pub limit_monthly_usd: Option<String>,
262 #[serde(rename = "testConfig", skip_serializing_if = "Option::is_none")]
264 pub test_config: Option<ProviderTestConfig>,
265 #[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
267 pub proxy_config: Option<ProviderProxyConfig>,
268 #[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
273 pub api_format: Option<String>,
274 #[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")]
276 pub prompt_cache_key: Option<String>,
277 #[serde(rename = "authBinding", skip_serializing_if = "Option::is_none")]
279 pub auth_binding: Option<AuthBinding>,
280 #[serde(rename = "apiKeyField", skip_serializing_if = "Option::is_none")]
282 pub api_key_field: Option<String>,
283 #[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")]
286 pub live_config_managed: Option<bool>,
287 #[serde(
289 rename = "codexModelProviderKey",
290 skip_serializing_if = "Option::is_none"
291 )]
292 pub codex_model_provider_key: Option<String>,
293 #[serde(rename = "providerType", skip_serializing_if = "Option::is_none")]
296 pub provider_type: Option<String>,
297 #[serde(rename = "githubAccountId", skip_serializing_if = "Option::is_none")]
300 pub github_account_id: Option<String>,
301}
302
303impl ProviderMeta {
304 pub fn managed_account_id_for(&self, auth_provider: &str) -> Option<String> {
305 if let Some(binding) = self.auth_binding.as_ref() {
306 if binding.source == AuthBindingSource::ManagedAccount
307 && binding.auth_provider.as_deref() == Some(auth_provider)
308 {
309 return binding.account_id.clone();
310 }
311 }
312
313 if auth_provider == "github_copilot" {
314 return self.github_account_id.clone();
315 }
316
317 None
318 }
319}
320
321impl ProviderManager {
322 pub fn get_all_providers(&self) -> &IndexMap<String, Provider> {
324 &self.providers
325 }
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct OpenCodeProviderConfig {
331 pub npm: String,
332 #[serde(skip_serializing_if = "Option::is_none")]
333 pub name: Option<String>,
334 #[serde(default)]
335 pub options: OpenCodeProviderOptions,
336 #[serde(default)]
337 pub models: HashMap<String, OpenCodeModel>,
338}
339
340impl Default for OpenCodeProviderConfig {
341 fn default() -> Self {
342 Self {
343 npm: "@ai-sdk/openai-compatible".to_string(),
344 name: None,
345 options: OpenCodeProviderOptions::default(),
346 models: HashMap::new(),
347 }
348 }
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize, Default)]
352pub struct OpenCodeProviderOptions {
353 #[serde(rename = "baseURL", skip_serializing_if = "Option::is_none")]
354 pub base_url: Option<String>,
355 #[serde(rename = "apiKey", skip_serializing_if = "Option::is_none")]
356 pub api_key: Option<String>,
357 #[serde(skip_serializing_if = "Option::is_none")]
358 pub headers: Option<HashMap<String, String>>,
359 #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
360 pub extra: HashMap<String, Value>,
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct OpenCodeModel {
365 pub name: String,
366 #[serde(skip_serializing_if = "Option::is_none")]
367 pub limit: Option<OpenCodeModelLimit>,
368 #[serde(skip_serializing_if = "Option::is_none")]
369 pub options: Option<HashMap<String, Value>>,
370 #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
371 pub extra: HashMap<String, Value>,
372}
373
374#[derive(Debug, Clone, Serialize, Deserialize, Default)]
375pub struct OpenCodeModelLimit {
376 #[serde(skip_serializing_if = "Option::is_none")]
377 pub context: Option<u64>,
378 #[serde(skip_serializing_if = "Option::is_none")]
379 pub output: Option<u64>,
380}
381
382#[derive(Debug, Clone, Serialize, Deserialize, Default)]
383#[serde(rename_all = "camelCase")]
384pub struct OpenClawProviderConfig {
385 #[serde(skip_serializing_if = "Option::is_none")]
386 pub base_url: Option<String>,
387 #[serde(skip_serializing_if = "Option::is_none")]
388 pub api_key: Option<String>,
389 #[serde(skip_serializing_if = "Option::is_none")]
390 pub api: Option<String>,
391 #[serde(default, skip_serializing_if = "Vec::is_empty")]
392 pub models: Vec<OpenClawModelEntry>,
393 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
394 pub headers: HashMap<String, String>,
395 #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
396 pub extra: HashMap<String, Value>,
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize, Default)]
400#[serde(rename_all = "camelCase")]
401pub struct OpenClawModelEntry {
402 pub id: String,
403 #[serde(skip_serializing_if = "Option::is_none")]
404 pub name: Option<String>,
405 #[serde(skip_serializing_if = "Option::is_none")]
406 pub alias: Option<String>,
407 #[serde(skip_serializing_if = "Option::is_none")]
408 pub cost: Option<OpenClawModelCost>,
409 #[serde(skip_serializing_if = "Option::is_none")]
410 pub context_window: Option<u32>,
411 #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
412 pub extra: HashMap<String, Value>,
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize, Default)]
416pub struct OpenClawModelCost {
417 pub input: f64,
418 pub output: f64,
419 #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
420 pub extra: HashMap<String, Value>,
421}
422
423#[cfg(test)]
424mod tests {
425 use super::{AuthBinding, AuthBindingSource, ProviderMeta};
426
427 #[test]
428 fn provider_meta_serializes_upstream_common_config_key_and_accepts_legacy_alias() {
429 let meta = ProviderMeta {
430 apply_common_config: Some(true),
431 ..Default::default()
432 };
433
434 let serialized = serde_json::to_value(&meta).expect("serialize provider meta");
435 assert_eq!(serialized["commonConfigEnabled"], true);
436 assert!(
437 serialized.get("applyCommonConfig").is_none(),
438 "serialization should prefer the upstream key name"
439 );
440
441 let deserialized: ProviderMeta = serde_json::from_value(serde_json::json!({
442 "applyCommonConfig": false
443 }))
444 .expect("deserialize legacy alias");
445 assert_eq!(deserialized.apply_common_config, Some(false));
446 }
447
448 #[test]
449 fn provider_meta_managed_account_id_for_prefers_binding_and_falls_back_to_legacy_github_id() {
450 let managed_meta = ProviderMeta {
451 auth_binding: Some(AuthBinding {
452 source: AuthBindingSource::ManagedAccount,
453 auth_provider: Some("github_copilot".to_string()),
454 account_id: Some("binding-account".to_string()),
455 }),
456 github_account_id: Some("legacy-account".to_string()),
457 ..Default::default()
458 };
459 assert_eq!(
460 managed_meta.managed_account_id_for("github_copilot"),
461 Some("binding-account".to_string())
462 );
463
464 let legacy_meta = ProviderMeta {
465 github_account_id: Some("legacy-account".to_string()),
466 ..Default::default()
467 };
468 assert_eq!(
469 legacy_meta.managed_account_id_for("github_copilot"),
470 Some("legacy-account".to_string())
471 );
472 assert_eq!(legacy_meta.managed_account_id_for("other_provider"), None);
473
474 let provider_config_meta = ProviderMeta {
475 auth_binding: Some(AuthBinding {
476 source: AuthBindingSource::ProviderConfig,
477 auth_provider: Some("github_copilot".to_string()),
478 account_id: Some("provider-config-account".to_string()),
479 }),
480 github_account_id: Some("legacy-account".to_string()),
481 ..Default::default()
482 };
483 assert_eq!(
484 provider_config_meta.managed_account_id_for("github_copilot"),
485 Some("legacy-account".to_string())
486 );
487 }
488}
489
490#[cfg(test)]
491mod issue_71_tests {
492 use super::*;
493 use serde_json::json;
494
495 #[test]
496 fn meta_with_both_common_config_keys_causes_duplicate_field_error() {
497 let meta_json = json!({
500 "commonConfigEnabled": true,
501 "applyCommonConfig": false
502 });
503
504 let result: Result<ProviderMeta, _> = serde_json::from_value(meta_json);
505 assert!(
506 result.is_err(),
507 "serde should reject duplicate rename+alias keys"
508 );
509 assert!(
510 result.unwrap_err().to_string().contains("duplicate field"),
511 "error should mention duplicate field"
512 );
513 }
514
515 #[test]
516 fn meta_with_only_alias_key_deserializes_ok() {
517 let meta_json = json!({
520 "applyCommonConfig": false
521 });
522
523 let result: ProviderMeta =
524 serde_json::from_value(meta_json).expect("should deserialize with alias only");
525 assert_eq!(result.apply_common_config, Some(false));
526 }
527
528 #[test]
529 fn provider_with_only_alias_in_meta_deserializes_ok() {
530 let provider_json = json!({
531 "id": "test",
532 "name": "Test",
533 "settingsConfig": {},
534 "meta": {
535 "applyCommonConfig": true
536 }
537 });
538 let provider: Provider =
539 serde_json::from_value(provider_json).expect("should deserialize provider");
540 assert_eq!(provider.meta.unwrap().apply_common_config, Some(true));
541 }
542
543 #[test]
544 fn provider_roundtrip_preserves_upstream_meta_fields_for_auth_and_type() {
545 let provider_json = json!({
546 "id": "test",
547 "name": "Test",
548 "settingsConfig": {},
549 "meta": {
550 "authBinding": {
551 "source": "managed_account",
552 "authProvider": "github_copilot",
553 "accountId": "acc-1"
554 },
555 "apiKeyField": "ANTHROPIC_AUTH_TOKEN",
556 "providerType": "github_copilot",
557 "githubAccountId": "gh-123"
558 }
559 });
560
561 let provider: Provider =
562 serde_json::from_value(provider_json).expect("provider should deserialize");
563 let serialized = serde_json::to_value(&provider).expect("provider should serialize");
564 let meta = serialized
565 .get("meta")
566 .and_then(|value| value.as_object())
567 .expect("meta should exist after roundtrip");
568
569 assert_eq!(
570 meta.get("authBinding")
571 .and_then(|value| value.get("source"))
572 .and_then(|value| value.as_str()),
573 Some("managed_account")
574 );
575 assert_eq!(
576 meta.get("authBinding")
577 .and_then(|value| value.get("authProvider"))
578 .and_then(|value| value.as_str()),
579 Some("github_copilot")
580 );
581 assert_eq!(
582 meta.get("authBinding")
583 .and_then(|value| value.get("accountId"))
584 .and_then(|value| value.as_str()),
585 Some("acc-1")
586 );
587 assert_eq!(
588 meta.get("apiKeyField").and_then(|value| value.as_str()),
589 Some("ANTHROPIC_AUTH_TOKEN")
590 );
591 assert_eq!(
592 meta.get("providerType").and_then(|value| value.as_str()),
593 Some("github_copilot")
594 );
595 assert_eq!(
596 meta.get("githubAccountId").and_then(|value| value.as_str()),
597 Some("gh-123")
598 );
599 }
600}