use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Provider {
pub id: String,
pub name: String,
#[serde(rename = "settingsConfig")]
pub settings_config: Value,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "websiteUrl")]
pub website_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "createdAt")]
pub created_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "sortIndex")]
pub sort_index: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<ProviderMeta>,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "iconColor")]
pub icon_color: Option<String>,
#[serde(default)]
#[serde(rename = "inFailoverQueue")]
pub in_failover_queue: bool,
}
impl Provider {
pub fn with_id(
id: String,
name: String,
settings_config: Value,
website_url: Option<String>,
) -> Self {
Self {
id,
name,
settings_config,
website_url,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderManager {
pub providers: IndexMap<String, Provider>,
pub current: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UsageScript {
pub enabled: bool,
pub language: String,
pub code: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "apiKey")]
pub api_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "baseUrl")]
pub base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "accessToken")]
pub access_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "userId")]
pub user_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "templateType")]
pub template_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "autoQueryInterval")]
pub auto_query_interval: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UsageData {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "planName")]
pub plan_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "isValid")]
pub is_valid: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "invalidMessage")]
pub invalid_message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub total: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub used: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remaining: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UsageResult {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Vec<UsageData>>, #[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderTestConfig {
#[serde(default)]
pub enabled: bool,
#[serde(rename = "testModel", skip_serializing_if = "Option::is_none")]
pub test_model: Option<String>,
#[serde(rename = "timeoutSecs", skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
#[serde(rename = "testPrompt", skip_serializing_if = "Option::is_none")]
pub test_prompt: Option<String>,
#[serde(
rename = "degradedThresholdMs",
skip_serializing_if = "Option::is_none"
)]
pub degraded_threshold_ms: Option<u64>,
#[serde(rename = "maxRetries", skip_serializing_if = "Option::is_none")]
pub max_retries: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderProxyConfig {
#[serde(default)]
pub enabled: bool,
#[serde(rename = "proxyType", skip_serializing_if = "Option::is_none")]
pub proxy_type: Option<String>,
#[serde(rename = "proxyHost", skip_serializing_if = "Option::is_none")]
pub proxy_host: Option<String>,
#[serde(rename = "proxyPort", skip_serializing_if = "Option::is_none")]
pub proxy_port: Option<u16>,
#[serde(rename = "proxyUsername", skip_serializing_if = "Option::is_none")]
pub proxy_username: Option<String>,
#[serde(rename = "proxyPassword", skip_serializing_if = "Option::is_none")]
pub proxy_password: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum AuthBindingSource {
#[default]
ProviderConfig,
ManagedAccount,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AuthBinding {
#[serde(default)]
pub source: AuthBindingSource,
#[serde(rename = "authProvider", skip_serializing_if = "Option::is_none")]
pub auth_provider: Option<String>,
#[serde(rename = "accountId", skip_serializing_if = "Option::is_none")]
pub account_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderMeta {
#[serde(
rename = "commonConfigEnabled",
alias = "applyCommonConfig",
skip_serializing_if = "Option::is_none"
)]
pub apply_common_config: Option<bool>,
#[serde(rename = "codexOfficial", skip_serializing_if = "Option::is_none")]
pub codex_official: Option<bool>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub custom_endpoints: HashMap<String, crate::settings::CustomEndpoint>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_script: Option<UsageScript>,
#[serde(rename = "endpointAutoSelect", skip_serializing_if = "Option::is_none")]
pub endpoint_auto_select: Option<bool>,
#[serde(rename = "isPartner", skip_serializing_if = "Option::is_none")]
pub is_partner: Option<bool>,
#[serde(
rename = "partnerPromotionKey",
skip_serializing_if = "Option::is_none"
)]
pub partner_promotion_key: Option<String>,
#[serde(rename = "costMultiplier", skip_serializing_if = "Option::is_none")]
pub cost_multiplier: Option<String>,
#[serde(rename = "pricingModelSource", skip_serializing_if = "Option::is_none")]
pub pricing_model_source: Option<String>,
#[serde(rename = "limitDailyUsd", skip_serializing_if = "Option::is_none")]
pub limit_daily_usd: Option<String>,
#[serde(rename = "limitMonthlyUsd", skip_serializing_if = "Option::is_none")]
pub limit_monthly_usd: Option<String>,
#[serde(rename = "testConfig", skip_serializing_if = "Option::is_none")]
pub test_config: Option<ProviderTestConfig>,
#[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
pub proxy_config: Option<ProviderProxyConfig>,
#[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
pub api_format: Option<String>,
#[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<String>,
#[serde(rename = "authBinding", skip_serializing_if = "Option::is_none")]
pub auth_binding: Option<AuthBinding>,
#[serde(rename = "apiKeyField", skip_serializing_if = "Option::is_none")]
pub api_key_field: Option<String>,
#[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")]
pub live_config_managed: Option<bool>,
#[serde(rename = "providerType", skip_serializing_if = "Option::is_none")]
pub provider_type: Option<String>,
#[serde(rename = "githubAccountId", skip_serializing_if = "Option::is_none")]
pub github_account_id: Option<String>,
}
impl ProviderMeta {
pub fn managed_account_id_for(&self, auth_provider: &str) -> Option<String> {
if let Some(binding) = self.auth_binding.as_ref() {
if binding.source == AuthBindingSource::ManagedAccount
&& binding.auth_provider.as_deref() == Some(auth_provider)
{
return binding.account_id.clone();
}
}
if auth_provider == "github_copilot" {
return self.github_account_id.clone();
}
None
}
}
impl ProviderManager {
pub fn get_all_providers(&self) -> &IndexMap<String, Provider> {
&self.providers
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenCodeProviderConfig {
pub npm: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default)]
pub options: OpenCodeProviderOptions,
#[serde(default)]
pub models: HashMap<String, OpenCodeModel>,
}
impl Default for OpenCodeProviderConfig {
fn default() -> Self {
Self {
npm: "@ai-sdk/openai-compatible".to_string(),
name: None,
options: OpenCodeProviderOptions::default(),
models: HashMap::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct OpenCodeProviderOptions {
#[serde(rename = "baseURL", skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(rename = "apiKey", skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub headers: Option<HashMap<String, String>>,
#[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenCodeModel {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<OpenCodeModelLimit>,
#[serde(skip_serializing_if = "Option::is_none")]
pub options: Option<HashMap<String, Value>>,
#[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct OpenCodeModelLimit {
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct OpenClawProviderConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub api: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub models: Vec<OpenClawModelEntry>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub headers: HashMap<String, String>,
#[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct OpenClawModelEntry {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub alias: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cost: Option<OpenClawModelCost>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_window: Option<u32>,
#[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct OpenClawModelCost {
pub input: f64,
pub output: f64,
#[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
pub extra: HashMap<String, Value>,
}
#[cfg(test)]
mod tests {
use super::{AuthBinding, AuthBindingSource, ProviderMeta};
#[test]
fn provider_meta_serializes_upstream_common_config_key_and_accepts_legacy_alias() {
let meta = ProviderMeta {
apply_common_config: Some(true),
..Default::default()
};
let serialized = serde_json::to_value(&meta).expect("serialize provider meta");
assert_eq!(serialized["commonConfigEnabled"], true);
assert!(
serialized.get("applyCommonConfig").is_none(),
"serialization should prefer the upstream key name"
);
let deserialized: ProviderMeta = serde_json::from_value(serde_json::json!({
"applyCommonConfig": false
}))
.expect("deserialize legacy alias");
assert_eq!(deserialized.apply_common_config, Some(false));
}
#[test]
fn provider_meta_managed_account_id_for_prefers_binding_and_falls_back_to_legacy_github_id() {
let managed_meta = ProviderMeta {
auth_binding: Some(AuthBinding {
source: AuthBindingSource::ManagedAccount,
auth_provider: Some("github_copilot".to_string()),
account_id: Some("binding-account".to_string()),
}),
github_account_id: Some("legacy-account".to_string()),
..Default::default()
};
assert_eq!(
managed_meta.managed_account_id_for("github_copilot"),
Some("binding-account".to_string())
);
let legacy_meta = ProviderMeta {
github_account_id: Some("legacy-account".to_string()),
..Default::default()
};
assert_eq!(
legacy_meta.managed_account_id_for("github_copilot"),
Some("legacy-account".to_string())
);
assert_eq!(legacy_meta.managed_account_id_for("other_provider"), None);
let provider_config_meta = ProviderMeta {
auth_binding: Some(AuthBinding {
source: AuthBindingSource::ProviderConfig,
auth_provider: Some("github_copilot".to_string()),
account_id: Some("provider-config-account".to_string()),
}),
github_account_id: Some("legacy-account".to_string()),
..Default::default()
};
assert_eq!(
provider_config_meta.managed_account_id_for("github_copilot"),
Some("legacy-account".to_string())
);
}
}
#[cfg(test)]
mod issue_71_tests {
use super::*;
use serde_json::json;
#[test]
fn meta_with_both_common_config_keys_causes_duplicate_field_error() {
let meta_json = json!({
"commonConfigEnabled": true,
"applyCommonConfig": false
});
let result: Result<ProviderMeta, _> = serde_json::from_value(meta_json);
assert!(
result.is_err(),
"serde should reject duplicate rename+alias keys"
);
assert!(
result.unwrap_err().to_string().contains("duplicate field"),
"error should mention duplicate field"
);
}
#[test]
fn meta_with_only_alias_key_deserializes_ok() {
let meta_json = json!({
"applyCommonConfig": false
});
let result: ProviderMeta =
serde_json::from_value(meta_json).expect("should deserialize with alias only");
assert_eq!(result.apply_common_config, Some(false));
}
#[test]
fn provider_with_only_alias_in_meta_deserializes_ok() {
let provider_json = json!({
"id": "test",
"name": "Test",
"settingsConfig": {},
"meta": {
"applyCommonConfig": true
}
});
let provider: Provider =
serde_json::from_value(provider_json).expect("should deserialize provider");
assert_eq!(provider.meta.unwrap().apply_common_config, Some(true));
}
#[test]
fn provider_roundtrip_preserves_upstream_meta_fields_for_auth_and_type() {
let provider_json = json!({
"id": "test",
"name": "Test",
"settingsConfig": {},
"meta": {
"authBinding": {
"source": "managed_account",
"authProvider": "github_copilot",
"accountId": "acc-1"
},
"apiKeyField": "ANTHROPIC_AUTH_TOKEN",
"providerType": "github_copilot",
"githubAccountId": "gh-123"
}
});
let provider: Provider =
serde_json::from_value(provider_json).expect("provider should deserialize");
let serialized = serde_json::to_value(&provider).expect("provider should serialize");
let meta = serialized
.get("meta")
.and_then(|value| value.as_object())
.expect("meta should exist after roundtrip");
assert_eq!(
meta.get("authBinding")
.and_then(|value| value.get("source"))
.and_then(|value| value.as_str()),
Some("managed_account")
);
assert_eq!(
meta.get("authBinding")
.and_then(|value| value.get("authProvider"))
.and_then(|value| value.as_str()),
Some("github_copilot")
);
assert_eq!(
meta.get("authBinding")
.and_then(|value| value.get("accountId"))
.and_then(|value| value.as_str()),
Some("acc-1")
);
assert_eq!(
meta.get("apiKeyField").and_then(|value| value.as_str()),
Some("ANTHROPIC_AUTH_TOKEN")
);
assert_eq!(
meta.get("providerType").and_then(|value| value.as_str()),
Some("github_copilot")
);
assert_eq!(
meta.get("githubAccountId").and_then(|value| value.as_str()),
Some("gh-123")
);
}
}