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(rename = "providerType", skip_serializing_if = "Option::is_none")]
290 pub provider_type: Option<String>,
291 #[serde(rename = "githubAccountId", skip_serializing_if = "Option::is_none")]
294 pub github_account_id: Option<String>,
295}
296
297impl ProviderMeta {
298 pub fn managed_account_id_for(&self, auth_provider: &str) -> Option<String> {
299 if let Some(binding) = self.auth_binding.as_ref() {
300 if binding.source == AuthBindingSource::ManagedAccount
301 && binding.auth_provider.as_deref() == Some(auth_provider)
302 {
303 return binding.account_id.clone();
304 }
305 }
306
307 if auth_provider == "github_copilot" {
308 return self.github_account_id.clone();
309 }
310
311 None
312 }
313}
314
315impl ProviderManager {
316 pub fn get_all_providers(&self) -> &IndexMap<String, Provider> {
318 &self.providers
319 }
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct OpenCodeProviderConfig {
325 pub npm: String,
326 #[serde(skip_serializing_if = "Option::is_none")]
327 pub name: Option<String>,
328 #[serde(default)]
329 pub options: OpenCodeProviderOptions,
330 #[serde(default)]
331 pub models: HashMap<String, OpenCodeModel>,
332}
333
334impl Default for OpenCodeProviderConfig {
335 fn default() -> Self {
336 Self {
337 npm: "@ai-sdk/openai-compatible".to_string(),
338 name: None,
339 options: OpenCodeProviderOptions::default(),
340 models: HashMap::new(),
341 }
342 }
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize, Default)]
346pub struct OpenCodeProviderOptions {
347 #[serde(rename = "baseURL", skip_serializing_if = "Option::is_none")]
348 pub base_url: Option<String>,
349 #[serde(rename = "apiKey", skip_serializing_if = "Option::is_none")]
350 pub api_key: Option<String>,
351 #[serde(skip_serializing_if = "Option::is_none")]
352 pub headers: Option<HashMap<String, String>>,
353 #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
354 pub extra: HashMap<String, Value>,
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct OpenCodeModel {
359 pub name: String,
360 #[serde(skip_serializing_if = "Option::is_none")]
361 pub limit: Option<OpenCodeModelLimit>,
362 #[serde(skip_serializing_if = "Option::is_none")]
363 pub options: Option<HashMap<String, Value>>,
364 #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
365 pub extra: HashMap<String, Value>,
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize, Default)]
369pub struct OpenCodeModelLimit {
370 #[serde(skip_serializing_if = "Option::is_none")]
371 pub context: Option<u64>,
372 #[serde(skip_serializing_if = "Option::is_none")]
373 pub output: Option<u64>,
374}
375
376#[derive(Debug, Clone, Serialize, Deserialize, Default)]
377#[serde(rename_all = "camelCase")]
378pub struct OpenClawProviderConfig {
379 #[serde(skip_serializing_if = "Option::is_none")]
380 pub base_url: Option<String>,
381 #[serde(skip_serializing_if = "Option::is_none")]
382 pub api_key: Option<String>,
383 #[serde(skip_serializing_if = "Option::is_none")]
384 pub api: Option<String>,
385 #[serde(default, skip_serializing_if = "Vec::is_empty")]
386 pub models: Vec<OpenClawModelEntry>,
387 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
388 pub headers: HashMap<String, String>,
389 #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
390 pub extra: HashMap<String, Value>,
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize, Default)]
394#[serde(rename_all = "camelCase")]
395pub struct OpenClawModelEntry {
396 pub id: String,
397 #[serde(skip_serializing_if = "Option::is_none")]
398 pub name: Option<String>,
399 #[serde(skip_serializing_if = "Option::is_none")]
400 pub alias: Option<String>,
401 #[serde(skip_serializing_if = "Option::is_none")]
402 pub cost: Option<OpenClawModelCost>,
403 #[serde(skip_serializing_if = "Option::is_none")]
404 pub context_window: Option<u32>,
405 #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
406 pub extra: HashMap<String, Value>,
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize, Default)]
410pub struct OpenClawModelCost {
411 pub input: f64,
412 pub output: f64,
413 #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
414 pub extra: HashMap<String, Value>,
415}
416
417#[cfg(test)]
418mod tests {
419 use super::{AuthBinding, AuthBindingSource, ProviderMeta};
420
421 #[test]
422 fn provider_meta_serializes_upstream_common_config_key_and_accepts_legacy_alias() {
423 let meta = ProviderMeta {
424 apply_common_config: Some(true),
425 ..Default::default()
426 };
427
428 let serialized = serde_json::to_value(&meta).expect("serialize provider meta");
429 assert_eq!(serialized["commonConfigEnabled"], true);
430 assert!(
431 serialized.get("applyCommonConfig").is_none(),
432 "serialization should prefer the upstream key name"
433 );
434
435 let deserialized: ProviderMeta = serde_json::from_value(serde_json::json!({
436 "applyCommonConfig": false
437 }))
438 .expect("deserialize legacy alias");
439 assert_eq!(deserialized.apply_common_config, Some(false));
440 }
441
442 #[test]
443 fn provider_meta_managed_account_id_for_prefers_binding_and_falls_back_to_legacy_github_id() {
444 let managed_meta = ProviderMeta {
445 auth_binding: Some(AuthBinding {
446 source: AuthBindingSource::ManagedAccount,
447 auth_provider: Some("github_copilot".to_string()),
448 account_id: Some("binding-account".to_string()),
449 }),
450 github_account_id: Some("legacy-account".to_string()),
451 ..Default::default()
452 };
453 assert_eq!(
454 managed_meta.managed_account_id_for("github_copilot"),
455 Some("binding-account".to_string())
456 );
457
458 let legacy_meta = ProviderMeta {
459 github_account_id: Some("legacy-account".to_string()),
460 ..Default::default()
461 };
462 assert_eq!(
463 legacy_meta.managed_account_id_for("github_copilot"),
464 Some("legacy-account".to_string())
465 );
466 assert_eq!(legacy_meta.managed_account_id_for("other_provider"), None);
467
468 let provider_config_meta = ProviderMeta {
469 auth_binding: Some(AuthBinding {
470 source: AuthBindingSource::ProviderConfig,
471 auth_provider: Some("github_copilot".to_string()),
472 account_id: Some("provider-config-account".to_string()),
473 }),
474 github_account_id: Some("legacy-account".to_string()),
475 ..Default::default()
476 };
477 assert_eq!(
478 provider_config_meta.managed_account_id_for("github_copilot"),
479 Some("legacy-account".to_string())
480 );
481 }
482}
483
484#[cfg(test)]
485mod issue_71_tests {
486 use super::*;
487 use serde_json::json;
488
489 #[test]
490 fn meta_with_both_common_config_keys_causes_duplicate_field_error() {
491 let meta_json = json!({
494 "commonConfigEnabled": true,
495 "applyCommonConfig": false
496 });
497
498 let result: Result<ProviderMeta, _> = serde_json::from_value(meta_json);
499 assert!(
500 result.is_err(),
501 "serde should reject duplicate rename+alias keys"
502 );
503 assert!(
504 result.unwrap_err().to_string().contains("duplicate field"),
505 "error should mention duplicate field"
506 );
507 }
508
509 #[test]
510 fn meta_with_only_alias_key_deserializes_ok() {
511 let meta_json = json!({
514 "applyCommonConfig": false
515 });
516
517 let result: ProviderMeta =
518 serde_json::from_value(meta_json).expect("should deserialize with alias only");
519 assert_eq!(result.apply_common_config, Some(false));
520 }
521
522 #[test]
523 fn provider_with_only_alias_in_meta_deserializes_ok() {
524 let provider_json = json!({
525 "id": "test",
526 "name": "Test",
527 "settingsConfig": {},
528 "meta": {
529 "applyCommonConfig": true
530 }
531 });
532 let provider: Provider =
533 serde_json::from_value(provider_json).expect("should deserialize provider");
534 assert_eq!(provider.meta.unwrap().apply_common_config, Some(true));
535 }
536
537 #[test]
538 fn provider_roundtrip_preserves_upstream_meta_fields_for_auth_and_type() {
539 let provider_json = json!({
540 "id": "test",
541 "name": "Test",
542 "settingsConfig": {},
543 "meta": {
544 "authBinding": {
545 "source": "managed_account",
546 "authProvider": "github_copilot",
547 "accountId": "acc-1"
548 },
549 "apiKeyField": "ANTHROPIC_AUTH_TOKEN",
550 "providerType": "github_copilot",
551 "githubAccountId": "gh-123"
552 }
553 });
554
555 let provider: Provider =
556 serde_json::from_value(provider_json).expect("provider should deserialize");
557 let serialized = serde_json::to_value(&provider).expect("provider should serialize");
558 let meta = serialized
559 .get("meta")
560 .and_then(|value| value.as_object())
561 .expect("meta should exist after roundtrip");
562
563 assert_eq!(
564 meta.get("authBinding")
565 .and_then(|value| value.get("source"))
566 .and_then(|value| value.as_str()),
567 Some("managed_account")
568 );
569 assert_eq!(
570 meta.get("authBinding")
571 .and_then(|value| value.get("authProvider"))
572 .and_then(|value| value.as_str()),
573 Some("github_copilot")
574 );
575 assert_eq!(
576 meta.get("authBinding")
577 .and_then(|value| value.get("accountId"))
578 .and_then(|value| value.as_str()),
579 Some("acc-1")
580 );
581 assert_eq!(
582 meta.get("apiKeyField").and_then(|value| value.as_str()),
583 Some("ANTHROPIC_AUTH_TOKEN")
584 );
585 assert_eq!(
586 meta.get("providerType").and_then(|value| value.as_str()),
587 Some("github_copilot")
588 );
589 assert_eq!(
590 meta.get("githubAccountId").and_then(|value| value.as_str()),
591 Some("gh-123")
592 );
593 }
594}