1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, BTreeSet};
4
5use super::Settings;
6
7#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
8#[serde(rename_all = "kebab-case")]
9pub enum CustomReasoningProtocol {
10 #[default]
11 GptLike,
12 AnthropicLike,
13}
14
15pub(crate) const MAX_CUSTOM_PROVIDER_REQUEST_HEADERS: usize = 32;
16
17#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
18#[serde(tag = "source", rename_all = "snake_case")]
19pub enum CustomProviderHeaderValue {
20 ConversationId,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
24pub struct CustomProviderConfig {
25 pub label: String,
26 pub base_url: String,
27 #[serde(
28 default,
29 deserialize_with = "deserialize_optional_env_var",
30 skip_serializing_if = "Option::is_none"
31 )]
32 pub api_key_env_var: Option<String>,
33 #[serde(
34 default,
35 deserialize_with = "deserialize_optional_models_dev_provider",
36 skip_serializing_if = "Option::is_none"
37 )]
38 pub models_dev_provider: Option<String>,
39 #[serde(
40 default,
41 deserialize_with = "deserialize_optional_fast_mode",
42 skip_serializing_if = "Option::is_none"
43 )]
44 pub fast_mode: Option<CustomProviderFastMode>,
45 #[serde(default, skip_serializing_if = "is_false")]
46 pub use_responses_endpoint: bool,
47 #[serde(default, skip_serializing_if = "is_false")]
48 pub supports_text_verbosity: bool,
49 #[serde(default, skip_serializing_if = "is_gpt_like")]
50 pub reasoning_protocol: CustomReasoningProtocol,
51 #[serde(
52 default,
53 deserialize_with = "deserialize_extra_models",
54 skip_serializing_if = "Vec::is_empty"
55 )]
56 pub extra_models: Vec<String>,
57 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
58 pub request_headers: BTreeMap<String, CustomProviderHeaderValue>,
59}
60
61pub(super) fn validate_custom_provider_settings(settings: &Settings) -> anyhow::Result<()> {
62 for (id, custom) in &settings.custom_providers {
63 validate_custom_provider_id(id).map_err(|error| {
64 anyhow::anyhow!("custom provider '{id}' has invalid provider id: {error}")
65 })?;
66 validate_custom_provider_label(&custom.label).map_err(|error| {
67 anyhow::anyhow!("custom provider '{id}' has invalid label: {error}")
68 })?;
69 normalize_custom_provider_base_url(&custom.base_url).map_err(|error| {
70 anyhow::anyhow!("custom provider '{id}' has invalid base_url: {error}")
71 })?;
72 if let Some(env_var) = &custom.api_key_env_var {
73 validate_env_var_name(env_var).map_err(|error| {
74 anyhow::anyhow!("custom provider '{id}' has invalid api_key_env_var: {error}")
75 })?;
76 }
77 if let Some(fast_mode) = &custom.fast_mode {
78 validate_custom_provider_fast_mode(fast_mode).map_err(|error| {
79 anyhow::anyhow!("custom provider '{id}' has invalid fast_mode: {error}")
80 })?;
81 }
82 if let Some(models_dev_provider) = &custom.models_dev_provider {
83 validate_models_dev_provider_namespace(models_dev_provider).map_err(|error| {
84 anyhow::anyhow!("custom provider '{id}' has invalid models_dev_provider: {error}")
85 })?;
86 }
87 normalized_extra_models(&custom.extra_models).map_err(|error| {
88 anyhow::anyhow!("custom provider '{id}' has invalid extra_models: {error}")
89 })?;
90 validate_custom_provider_request_headers(id, &custom.request_headers)?;
91 }
92 Ok(())
93}
94
95fn validate_custom_provider_request_headers(
96 provider_id: &str,
97 headers: &BTreeMap<String, CustomProviderHeaderValue>,
98) -> anyhow::Result<()> {
99 if headers.len() > MAX_CUSTOM_PROVIDER_REQUEST_HEADERS {
100 anyhow::bail!(
101 "custom provider '{provider_id}' request_headers must contain at most {MAX_CUSTOM_PROVIDER_REQUEST_HEADERS} entries"
102 );
103 }
104 let mut normalized = BTreeSet::new();
105 for name in headers.keys() {
106 reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
107 anyhow::anyhow!(
108 "custom provider '{provider_id}' has invalid request header name '{name}'"
109 )
110 })?;
111 let lower = name.to_ascii_lowercase();
112 if !normalized.insert(lower.clone()) {
113 anyhow::bail!(
114 "custom provider '{provider_id}' has duplicate request header name '{name}' (names are case-insensitive)"
115 );
116 }
117 if matches!(
118 lower.as_str(),
119 "accept"
120 | "authorization"
121 | "content-length"
122 | "content-type"
123 | "host"
124 | "proxy-authorization"
125 | "transfer-encoding"
126 | "user-agent"
127 ) {
128 anyhow::bail!(
129 "custom provider '{provider_id}' request_headers must not override transport-owned header '{name}'"
130 );
131 }
132 }
133 Ok(())
134}
135
136fn validate_custom_provider_label(label: &str) -> anyhow::Result<()> {
137 let label = label.trim();
138 if label.is_empty() || label.len() > 100 {
139 anyhow::bail!("custom provider label must be non-empty and at most 100 characters");
140 }
141 if looks_like_secret_label(label) {
142 anyhow::bail!("custom provider label must not look like a secret value");
143 }
144 Ok(())
145}
146
147fn looks_like_secret_label(value: &str) -> bool {
148 let value = value.trim();
149 value.starts_with("sk-")
150 || value.starts_with("Bearer ")
151 || value.contains('=')
152 || (value.len() >= 48
153 && value
154 .chars()
155 .filter(|ch| ch.is_ascii_alphanumeric())
156 .count()
157 >= 40)
158}
159
160pub(crate) fn validate_custom_provider_id(id: &str) -> anyhow::Result<String> {
161 let id = id.trim();
162 if matches!(
163 id,
164 crate::providers::OPENAI_CODEX_PROVIDER
165 | crate::providers::ANTHROPIC_PROVIDER
166 | "claude-code"
167 | "openai"
168 ) {
169 anyhow::bail!("custom provider id '{id}' is reserved");
170 }
171 if id.len() > 63
172 || id.is_empty()
173 || !id.as_bytes()[0].is_ascii_lowercase()
174 || id.ends_with('-')
175 || !id
176 .chars()
177 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
178 {
179 anyhow::bail!(
180 "custom provider id must match ^[a-z][a-z0-9-]{{0,62}}$ with no trailing hyphen"
181 );
182 }
183 Ok(id.to_string())
184}
185
186pub(crate) fn looks_like_secret_value(value: &str) -> bool {
187 let value = value.trim();
188 value.starts_with("sk-")
189 || value.starts_with("Bearer ")
190 || value.contains('=')
191 || value.chars().any(char::is_whitespace)
192 || (value.len() >= 48
193 && value
194 .chars()
195 .filter(|ch| ch.is_ascii_alphanumeric())
196 .count()
197 >= 40)
198}
199
200pub(crate) fn validate_env_var_name(name: &str) -> anyhow::Result<String> {
201 let name = name.trim();
202 if looks_like_secret_value(name) {
203 anyhow::bail!(
204 "API key environment variable name looks like a secret value; enter a variable name such as CUSTOM_PROVIDER_API_KEY"
205 );
206 }
207 if name.is_empty()
208 || !(name.as_bytes()[0].is_ascii_uppercase() || name.as_bytes()[0] == b'_')
209 || !name
210 .chars()
211 .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
212 {
213 anyhow::bail!("API key environment variable name must match ^[A-Z_][A-Z0-9_]*$");
214 }
215 Ok(name.to_string())
216}
217
218pub(crate) fn validate_optional_env_var_name(name: &str) -> anyhow::Result<Option<String>> {
219 if name.trim().is_empty() {
220 return Ok(None);
221 }
222 validate_env_var_name(name).map(Some)
223}
224
225pub(crate) fn normalized_extra_models(extra_models: &[String]) -> anyhow::Result<Vec<String>> {
226 if extra_models.len() > 64 {
227 anyhow::bail!("extra_models must contain at most 64 model ids");
228 }
229 let mut seen = BTreeSet::new();
230 let mut normalized = Vec::new();
231 for model in extra_models {
232 let model = model.trim();
233 if model.is_empty() {
234 anyhow::bail!("extra_models entries must be non-empty");
235 }
236 if model.len() > 200 {
237 anyhow::bail!("extra_models entries must be at most 200 bytes");
238 }
239 if model
240 .chars()
241 .any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace())
242 {
243 anyhow::bail!(
244 "extra_models entries must not contain ASCII control characters or whitespace"
245 );
246 }
247 if looks_like_secret_value(model) {
248 anyhow::bail!("extra_models entries must not look like secret values");
249 }
250 if seen.insert(model.to_string()) {
251 normalized.push(model.to_string());
252 }
253 }
254 Ok(normalized)
255}
256
257pub(crate) fn validate_models_dev_provider_namespace(namespace: &str) -> anyhow::Result<String> {
258 let namespace = namespace.trim();
259 if looks_like_secret_value(namespace) {
260 anyhow::bail!(
261 "models.dev provider namespace looks like a secret value; enter a namespace such as openai"
262 );
263 }
264 if namespace.len() > 63
265 || namespace.is_empty()
266 || !namespace.as_bytes()[0].is_ascii_lowercase()
267 || namespace.ends_with('-')
268 || !namespace
269 .chars()
270 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
271 {
272 anyhow::bail!(
273 "models.dev provider namespace must match ^[a-z][a-z0-9-]{{0,62}}$ with no trailing hyphen"
274 );
275 }
276 Ok(namespace.to_string())
277}
278
279pub(crate) fn derive_custom_provider_id(label: &str) -> anyhow::Result<String> {
280 let mut id = String::new();
281 let mut last_was_separator = false;
282 for ch in label.trim().chars() {
283 if ch.is_ascii_alphanumeric() {
284 id.push(ch.to_ascii_lowercase());
285 last_was_separator = false;
286 } else if !last_was_separator && !id.is_empty() {
287 id.push('-');
288 last_was_separator = true;
289 }
290 }
291 while id.ends_with('-') {
292 id.pop();
293 }
294 validate_custom_provider_id(&id)
295 .map_err(|_| anyhow::anyhow!("custom provider label must derive a provider id matching ^[a-z][a-z0-9-]{{0,62}}$ and must not be reserved"))
296}
297
298pub(crate) fn normalize_custom_provider_base_url(input: &str) -> anyhow::Result<String> {
299 let value = input.trim().trim_end_matches('/');
300 let parsed = reqwest::Url::parse(value)
301 .map_err(|_| anyhow::anyhow!("custom provider base URL must be a valid URL"))?;
302 if !parsed.username().is_empty() || parsed.password().is_some() {
303 anyhow::bail!("custom provider base URL must not include URL credentials or userinfo");
304 }
305 if parsed.query().is_some() || parsed.fragment().is_some() {
306 anyhow::bail!("custom provider base URL must not include query parameters or fragments");
307 }
308 let path = parsed.path().trim_end_matches('/');
309 if path.ends_with("/responses")
310 || path.ends_with("/models")
311 || path.ends_with("/completions")
312 || path.ends_with("/chat/completions")
313 {
314 anyhow::bail!("custom provider base URL must be an API root, not an endpoint URL");
315 }
316 match parsed.scheme() {
317 "https" | "http" => Ok(value.to_string()),
318 _ => anyhow::bail!("custom provider base URL must use http:// or https://"),
319 }
320}
321
322pub(crate) fn make_custom_provider_config(
323 label: &str,
324 base_url: &str,
325 api_key_env_var: &str,
326) -> anyhow::Result<CustomProviderConfig> {
327 let label = label.trim();
328 validate_custom_provider_label(label)?;
329 Ok(CustomProviderConfig {
330 label: label.to_string(),
331 base_url: normalize_custom_provider_base_url(base_url)?,
332 api_key_env_var: validate_optional_env_var_name(api_key_env_var)?,
333 models_dev_provider: None,
334 fast_mode: None,
335 use_responses_endpoint: false,
336 supports_text_verbosity: false,
337 reasoning_protocol: CustomReasoningProtocol::default(),
338 extra_models: Vec::new(),
339 request_headers: BTreeMap::new(),
340 })
341}
342
343fn validate_custom_provider_fast_mode(fast_mode: &CustomProviderFastMode) -> anyhow::Result<()> {
344 validate_fast_service_tier(&fast_mode.service_tier)?;
345 validate_fast_models(&fast_mode.models)?;
346 Ok(())
347}
348
349fn validate_fast_service_tier(service_tier: &str) -> anyhow::Result<String> {
350 let service_tier = service_tier.trim();
351 if service_tier.is_empty()
352 || service_tier.len() > 64
353 || !service_tier.bytes().all(|byte| {
354 byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-' || byte == b'.'
355 })
356 {
357 anyhow::bail!(
358 "fast_mode.service_tier must be a non-empty ASCII identifier of at most 64 characters"
359 );
360 }
361 if looks_like_secret_value(service_tier) {
362 anyhow::bail!("fast_mode.service_tier must not look like a secret value");
363 }
364 Ok(service_tier.to_string())
365}
366
367fn validate_fast_models(models: &[String]) -> anyhow::Result<()> {
368 if models.is_empty() || models.len() > 64 {
369 anyhow::bail!("fast_mode.models must contain 1 to 64 model ids");
370 }
371 if models.iter().any(|model| model == "*") && models.len() != 1 {
372 anyhow::bail!("fast_mode.models wildcard must be the sole member");
373 }
374 let mut seen = BTreeSet::new();
375 for model in models {
376 if model.is_empty()
377 || model.chars().count() > 200
378 || model
379 .chars()
380 .any(|ch| ch.is_whitespace() || ch.is_control())
381 {
382 anyhow::bail!(
383 "fast_mode.models entries must be non-empty model ids of at most 200 characters without whitespace or control characters"
384 );
385 }
386 if model != "*" && looks_like_secret_value(model) {
387 anyhow::bail!("fast_mode.models entries must not look like secret values");
388 }
389 if !seen.insert(model.as_str()) {
390 anyhow::bail!("fast_mode.models must not contain duplicate model ids");
391 }
392 }
393 Ok(())
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
397pub struct CustomProviderFastMode {
398 pub service_tier: String,
399 pub models: Vec<String>,
400}
401
402#[derive(JsonSchema)]
403#[allow(dead_code)]
404struct CustomProviderFastModeSchema {
405 #[schemars(regex(pattern = r"^\s*[A-Za-z0-9_.-]{1,64}\s*$"))]
406 service_tier: String,
407 #[schemars(
408 length(min = 1, max = 64),
409 inner(regex(pattern = r"^\S{1,200}$")),
410 transform = add_fast_models_schema_constraints
411 )]
412 models: Vec<String>,
413}
414
415fn add_fast_models_schema_constraints(schema: &mut schemars::Schema) {
416 let object = schema.ensure_object();
417 object.insert("uniqueItems".to_string(), serde_json::json!(true));
418 object.insert(
419 "oneOf".to_string(),
420 serde_json::json!([
421 {
422 "contains": {"pattern": r"^\*$"},
423 "maxItems": 1
424 },
425 {
426 "not": {"contains": {"pattern": r"^\*$"}}
427 }
428 ]),
429 );
430}
431
432impl JsonSchema for CustomProviderFastMode {
433 fn schema_name() -> std::borrow::Cow<'static, str> {
434 "CustomProviderFastMode".into()
435 }
436
437 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
438 CustomProviderFastModeSchema::json_schema(generator)
439 }
440}
441
442#[cfg(test)]
443impl CustomProviderFastMode {
444 pub(crate) fn supports_model(&self, model: &str) -> bool {
445 self.models
446 .iter()
447 .any(|candidate| candidate == "*" || candidate == model)
448 }
449}
450fn is_gpt_like(value: &CustomReasoningProtocol) -> bool {
451 *value == CustomReasoningProtocol::GptLike
452}
453
454fn is_false(value: &bool) -> bool {
455 !*value
456}
457
458fn deserialize_optional_env_var<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
459where
460 D: serde::Deserializer<'de>,
461{
462 let value = Option::<String>::deserialize(deserializer)?;
463 Ok(value.and_then(|value| {
464 let trimmed = value.trim();
465 if trimmed.is_empty() {
466 None
467 } else {
468 Some(trimmed.to_string())
469 }
470 }))
471}
472
473fn deserialize_optional_fast_mode<'de, D>(
474 deserializer: D,
475) -> Result<Option<CustomProviderFastMode>, D::Error>
476where
477 D: serde::Deserializer<'de>,
478{
479 let value = Option::<CustomProviderFastMode>::deserialize(deserializer)?;
480 Ok(value.map(|mut fast| {
481 fast.service_tier = fast.service_tier.trim().to_string();
482 fast
483 }))
484}
485
486fn deserialize_optional_models_dev_provider<'de, D>(
487 deserializer: D,
488) -> Result<Option<String>, D::Error>
489where
490 D: serde::Deserializer<'de>,
491{
492 let value = Option::<String>::deserialize(deserializer)?;
493 Ok(value.and_then(|value| {
494 let trimmed = value.trim();
495 if trimmed.is_empty() {
496 None
497 } else {
498 Some(trimmed.to_string())
499 }
500 }))
501}
502
503fn deserialize_extra_models<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
504where
505 D: serde::Deserializer<'de>,
506{
507 let values = Vec::<String>::deserialize(deserializer)?;
508 Ok(values
509 .into_iter()
510 .map(|value| value.trim().to_string())
511 .collect())
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517
518 fn settings_with_fast_models(models: Vec<String>) -> Settings {
519 Settings {
520 custom_providers: [(
521 "provider".to_string(),
522 CustomProviderConfig {
523 label: "Provider".to_string(),
524 base_url: "https://example.test".to_string(),
525 api_key_env_var: None,
526 models_dev_provider: None,
527 fast_mode: Some(CustomProviderFastMode {
528 service_tier: "priority".to_string(),
529 models,
530 }),
531 use_responses_endpoint: false,
532 supports_text_verbosity: false,
533 reasoning_protocol: CustomReasoningProtocol::default(),
534 extra_models: Vec::new(),
535 request_headers: BTreeMap::new(),
536 },
537 )]
538 .into(),
539 ..Settings::default()
540 }
541 }
542
543 #[test]
544 fn fast_mode_trims_service_tier_but_preserves_exact_model_ids() {
545 let config: CustomProviderConfig = serde_json::from_value(serde_json::json!({
546 "label": "Provider", "base_url": "https://example.test",
547 "fast_mode": {"service_tier": " priority ", "models": [" model-a "]}
548 }))
549 .unwrap();
550 let fast_mode = config.fast_mode.as_ref().unwrap();
551 assert_eq!(fast_mode.service_tier, "priority");
552 assert_eq!(fast_mode.models, vec![" model-a ".to_string()]);
553 assert!(!fast_mode.supports_model("model-a"));
554 assert!(fast_mode.supports_model(" model-a "));
555 assert!(
556 validate_custom_provider_settings(&settings_with_fast_models(fast_mode.models.clone()))
557 .is_err()
558 );
559 }
560
561 #[test]
562 fn request_headers_parse_conversation_source_and_reject_owned_or_duplicate_names() {
563 let config: CustomProviderConfig = serde_json::from_value(serde_json::json!({
564 "label": "Provider",
565 "base_url": "https://example.test",
566 "request_headers": {
567 "x-opencode-session": {"source": "conversation_id"}
568 }
569 }))
570 .unwrap();
571 assert_eq!(
572 config.request_headers.get("x-opencode-session"),
573 Some(&CustomProviderHeaderValue::ConversationId)
574 );
575
576 for headers in [
577 BTreeMap::from([(
578 "Authorization".to_string(),
579 CustomProviderHeaderValue::ConversationId,
580 )]),
581 BTreeMap::from([
582 (
583 "X-Session".to_string(),
584 CustomProviderHeaderValue::ConversationId,
585 ),
586 (
587 "x-session".to_string(),
588 CustomProviderHeaderValue::ConversationId,
589 ),
590 ]),
591 ] {
592 assert!(validate_custom_provider_request_headers("provider", &headers).is_err());
593 }
594 }
595
596 #[test]
597 fn fast_mode_runtime_validation_uses_unicode_character_limits_and_rejects_whitespace() {
598 for models in [
599 vec![" model".to_string()],
600 vec!["model ".to_string()],
601 vec!["model id".to_string()],
602 vec!["model\u{2003}id".to_string()],
603 vec!["model\u{0000}id".to_string()],
604 ] {
605 assert!(validate_custom_provider_settings(&settings_with_fast_models(models)).is_err());
606 }
607 assert!(
608 validate_custom_provider_settings(&settings_with_fast_models(vec!["😀".repeat(200),]))
609 .is_ok()
610 );
611 assert!(
612 validate_custom_provider_settings(&settings_with_fast_models(vec!["😀".repeat(201),]))
613 .is_err()
614 );
615 }
616
617 #[test]
618 fn fast_mode_rejects_duplicate_models_and_non_sole_wildcard() {
619 for models in [
620 vec!["model".to_string(), "model".to_string()],
621 vec!["*".to_string(), "model".to_string()],
622 ] {
623 assert!(validate_custom_provider_settings(&settings_with_fast_models(models)).is_err());
624 }
625 }
626}