1use std::collections::{BTreeMap, BTreeSet};
2
3use cookie_agent_identity::{ProviderId, ProviderModelId, VariantId};
4use serde::Serialize;
5
6use crate::{
7 HeaderName, ModelCapabilities, ProviderOptions, SafeStaticHeaderValue, Sha256Digest,
8 adapters::{
9 OvenAdapterFamily, custom_setup_recipe, validate_capability_ceiling,
10 validate_custom_endpoint, validate_managed_base_url, wire_adapter_for_custom,
11 },
12 authoring::{
13 AuthDefinition, CustomProvider, ManagedModelOverride, ModelsDevProvider, RequestDefaults,
14 validate_header_limits, validate_header_ownership,
15 },
16 catalog::{CatalogModelRecord, CatalogProviderRecord},
17 compiler::{
18 fingerprint::fingerprint,
19 projection::{
20 capabilities_from_catalog, managed_defaults, validate_capability_shape,
21 validate_defaults,
22 },
23 variants::{CompiledVariant, custom_variants, managed_variants},
24 },
25 recipes::{
26 COMPILER_VERSION, FamilyKind, FamilyRecipe, FamilyRecipeRegistry, ValidatedSetup,
27 auth_method, family_registry, placeholders, resolve_model, substitute_placeholders,
28 validate_auth_definition, validate_setup,
29 },
30};
31
32#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
33#[serde(rename_all = "snake_case")]
34pub enum AuthSourceCategory {
35 AuthoredApiKey,
36 AuthoredOverride,
37 AuthoredCustom,
38 Unavailable,
39}
40
41#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
42pub struct CompiledAuthShape {
43 pub method: String,
44 pub safe_parameters: BTreeMap<String, String>,
45 pub credential_fields: Vec<String>,
46 pub owned_headers: Vec<String>,
47 pub source: AuthSourceCategory,
48}
49
50#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
51#[serde(rename_all = "snake_case")]
52pub enum CompiledModelStatus {
53 Available,
54 CredentialsUnavailable,
55 SetupUnavailable,
56}
57
58#[derive(Clone, Debug, Serialize)]
59pub struct CompiledDynamicModel {
60 pub custom: bool,
64 pub id: ProviderModelId,
65 pub wire_model_id: crate::authoring::WireModelId,
66 pub display_name: String,
67 pub family_id: String,
68 pub effective_npm: String,
69 pub adapter_id: String,
70 pub resolved_shape: String,
71 pub reasoning_field: String,
72 pub adapter: OvenAdapterFamily,
73 pub endpoint: Option<String>,
74 pub setup: Option<ValidatedSetup>,
75 pub auth: CompiledAuthShape,
76 pub capabilities: ModelCapabilities,
77 pub replay_declaration: Option<crate::ReplayCapability>,
78 pub defaults: RequestDefaults,
79 pub options: ProviderOptions,
80 pub headers: BTreeMap<HeaderName, SafeStaticHeaderValue>,
81 #[serde(skip)]
82 pub cost: Option<crate::catalog::CatalogModelCost>,
83 pub variants: BTreeMap<VariantId, CompiledVariant>,
84 pub variant_order: Vec<VariantId>,
85 pub default_variant: Option<VariantId>,
86 pub status: CompiledModelStatus,
87 pub behavior_fingerprint: Sha256Digest,
88}
89
90impl CompiledDynamicModel {
91 pub(crate) fn selected(
92 &self,
93 options: &ProviderOptions,
94 model_id: Option<&crate::authoring::WireModelId>,
95 ) -> Result<Self, DynamicCompileError> {
96 let mut selected = self.clone();
97 if let Some(model_id) = model_id {
98 selected.wire_model_id = model_id.clone();
99 }
100 let compatible = self.adapter_id.starts_with("oven.openai-compatible.");
101 let family = if compatible {
102 OvenAdapterFamily::OpenaiCompatible
103 } else {
104 self.adapter
105 };
106 selected.adapter = if options.request_endpoint.is_none() {
107 self.adapter
108 } else {
109 family.with_endpoint(options.request_endpoint)?
110 };
111 selected.resolved_shape = if matches!(
112 selected.adapter,
113 OvenAdapterFamily::OpenaiResponses | OvenAdapterFamily::AzureOpenaiResponses
114 ) {
115 "responses"
116 } else {
117 "chat"
118 }
119 .into();
120 selected.adapter_id = if compatible {
121 self.adapter_id.replacen(
122 if self.adapter == OvenAdapterFamily::OpenaiResponses {
123 ".responses"
124 } else {
125 ".chat"
126 },
127 if selected.adapter == OvenAdapterFamily::OpenaiResponses {
128 ".responses"
129 } else {
130 ".chat"
131 },
132 1,
133 )
134 } else if selected.adapter == self.adapter {
135 self.adapter_id.clone()
136 } else {
137 selected.adapter.protocol_recipe().into()
138 };
139 selected.options = options.clone();
140 selected.capabilities.native_replay = self.replay_declaration.unwrap_or_else(|| {
141 automatic_replay(
142 selected.adapter,
143 selected.capabilities.reasoning,
144 selected.setup.as_ref(),
145 )
146 });
147 Ok(selected)
148 }
149
150 fn validate_settings(&self) -> Result<(), DynamicCompileError> {
151 let compatible_accounts = self.adapter_id.starts_with("oven.openai-compatible.")
152 || self.auth.method == "no-auth-v1"
153 && self.adapter == OvenAdapterFamily::OpenaiResponses;
154 if compatible_accounts
155 && (self.options.organization.is_some() || self.options.project.is_some())
156 {
157 return Err(DynamicCompileError::EndpointSelection("organization/project adaptor_options require official OpenAI authentication, not compatible or unauthenticated Responses".into()));
158 }
159 if self.options.store == Some(true) {
160 return Err(DynamicCompileError::EndpointSelection("adaptor_options.store = true is unsupported: the integrated Responses encoder uses stateless store = false requests".into()));
161 }
162 let validate = |selected: &Self,
163 defaults: &RequestDefaults,
164 reasoning: Option<&crate::ReasoningBehavior>| {
165 validate_capability_shape(&selected.capabilities)
166 && validate_capability_ceiling(selected.adapter, &selected.capabilities).is_ok()
167 && validate_defaults(defaults, &selected.capabilities)
168 && validate_custom_options(&selected.options, selected.adapter)
169 && (reasoning.is_none() || selected.capabilities.reasoning)
170 && reasoning_supported(reasoning, selected.adapter)
171 };
172 if !validate(self, &self.defaults, None) {
173 return Err(DynamicCompileError::CustomModel);
174 }
175 for variant in self.variants.values() {
176 let selected = self.selected(&variant.options, variant.model_id.as_ref())?;
177 if compatible_accounts
178 && (selected.options.organization.is_some() || selected.options.project.is_some())
179 {
180 return Err(DynamicCompileError::EndpointSelection(format!(
181 "variant `{}`: organization/project adaptor_options require official OpenAI authentication",
182 variant.id
183 )));
184 }
185 if selected.options.store == Some(true) {
186 return Err(DynamicCompileError::EndpointSelection(format!(
187 "variant `{}`: adaptor_options.store = true is unsupported by the stateless Responses encoder",
188 variant.id
189 )));
190 }
191 if !validate(&selected, &variant.defaults, variant.reasoning.as_ref()) {
192 return Err(DynamicCompileError::Variant);
193 }
194 }
195 Ok(())
196 }
197}
198
199fn automatic_replay(
200 adapter: OvenAdapterFamily,
201 reasoning: bool,
202 _setup: Option<&ValidatedSetup>,
203) -> crate::ReplayCapability {
204 adapter.automatic_replay(reasoning)
205}
206
207pub(crate) fn provider_wire_model_id(
208 id: &ProviderModelId,
209 adapter: OvenAdapterFamily,
210 setup: Option<&ValidatedSetup>,
211) -> Result<crate::authoring::WireModelId, DynamicCompileError> {
212 let wire = if matches!(
213 adapter,
214 OvenAdapterFamily::AzureOpenaiChat | OvenAdapterFamily::AzureOpenaiResponses
215 ) {
216 setup
217 .and_then(|setup| setup.values.get("deployment"))
218 .map_or(id.as_str(), String::as_str)
219 } else {
220 id.as_str()
221 };
222 crate::authoring::WireModelId::new(wire)
223 .map_err(|reason| DynamicCompileError::EndpointSelection(reason.into()))
224}
225
226#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
227pub struct UnsupportedModel {
228 pub id: ProviderModelId,
229 pub reason: String,
230}
231
232#[derive(Clone, Debug, Serialize)]
233pub struct CompiledDynamicProvider {
234 pub id: ProviderId,
235 pub models: BTreeMap<ProviderModelId, CompiledDynamicModel>,
236 pub unsupported_models: Vec<UnsupportedModel>,
237 pub fingerprint: Sha256Digest,
238}
239
240#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
241pub enum DynamicCompileError {
242 #[error("no_known_protocol_family")]
243 UnsupportedProvider,
244 #[error("unknown_model_override")]
245 UnknownModelOverride,
246 #[error("invalid_setup")]
247 Setup,
248 #[error("invalid_endpoint")]
249 Endpoint,
250 #[error("{0}")]
251 EndpointSelection(String),
252 #[error("invalid_auth")]
253 Auth,
254 #[error("authored_base_url_requires_auth")]
255 BaseUrlWithoutAuth,
256 #[error("unsupported_adaptor")]
257 UnsupportedAdapter,
258 #[error("{0}")]
259 StaticHeaders(#[source] crate::authoring::AuthoringError),
260 #[error(
261 "invalid model settings: check generation_options/adaptor_options against the selected endpoint and capabilities; tool_calling = false requires parallel_tool_calls = false"
262 )]
263 CustomModel,
264 #[error(
265 "invalid variant: check inherited settings against the selected endpoint, enabled = false conflicts, and default_variant"
266 )]
267 Variant,
268 #[error("invalid cache config: {0}")]
269 Cache(String),
270}
271
272#[derive(Clone, Copy, Debug)]
273pub struct DynamicCompiler {
274 registry: FamilyRecipeRegistry,
275}
276
277impl Default for DynamicCompiler {
278 fn default() -> Self {
279 Self::family_registry()
280 }
281}
282
283impl DynamicCompiler {
284 #[must_use]
285 pub const fn family_registry() -> Self {
286 Self {
287 registry: family_registry(),
288 }
289 }
290
291 pub fn compile_managed(
292 &self,
293 catalog_revision: &str,
294 record: &CatalogProviderRecord,
295 authored: Option<&ModelsDevProvider>,
296 ) -> Result<CompiledDynamicProvider, DynamicCompileError> {
297 self.compile_managed_with_headers(catalog_revision, record, authored, &BTreeMap::new())
298 }
299
300 pub fn compile_managed_with_headers(
301 &self,
302 catalog_revision: &str,
303 record: &CatalogProviderRecord,
304 authored: Option<&ModelsDevProvider>,
305 global_headers: &BTreeMap<HeaderName, SafeStaticHeaderValue>,
306 ) -> Result<CompiledDynamicProvider, DynamicCompileError> {
307 let family = self
308 .registry
309 .classify(record)
310 .ok_or(DynamicCompileError::UnsupportedProvider)?;
311 validate_managed_cache(record, authored, family)?;
312 if let Some(authored) = authored
313 && let Some(base_url) = authored.base_url.as_ref()
314 {
315 validate_managed_base_url(
316 crate::recipes::EndpointPolicy::DefaultWithAuthoredHttpsOverride {
317 default: "https://invalid.example",
318 },
319 Some(base_url),
320 )
321 .map_err(|_| DynamicCompileError::Endpoint)?;
322 }
323 if authored.is_some_and(|value| {
324 value.base_url.is_some() && value.api_key.is_none() && value.auth_override.is_none()
325 }) {
326 return Err(DynamicCompileError::BaseUrlWithoutAuth);
327 }
328 if let Some(authored) = authored {
329 for id in authored.model_overrides.keys() {
330 if !record.models.contains_key(id) {
331 return Err(DynamicCompileError::UnknownModelOverride);
332 }
333 }
334 }
335 let mut models = BTreeMap::new();
336 let mut unsupported_models = Vec::new();
337 for (table_id, entry) in &record.models {
338 let Some(model) = entry.record.as_ref() else {
339 continue;
340 };
341 if model.status == crate::catalog::CatalogModelStatus::Deprecated
342 || !model.modalities.output.iter().any(|value| value == "text")
343 {
344 continue;
345 }
346 let override_ = authored.and_then(|value| value.model_overrides.get(table_id));
347 if override_.and_then(|value| value.enabled) == Some(false) {
348 continue;
349 }
350 let resolved = match resolve_model(record, model, None, None) {
351 Ok(resolved) => resolved,
352 Err(error) => {
353 unsupported_models.push(UnsupportedModel {
354 id: table_id.clone(),
355 reason: error.to_string(),
356 });
357 continue;
358 }
359 };
360 match self.compile_managed_model(
361 catalog_revision,
362 &record.id,
363 model,
364 family,
365 &resolved,
366 authored,
367 override_,
368 global_headers,
369 ) {
370 Ok(compiled) => {
371 models.insert(table_id.clone(), compiled);
372 }
373 Err(ModelLocalError::Unsupported(reason)) => {
374 unsupported_models.push(UnsupportedModel {
375 id: table_id.clone(),
376 reason,
377 });
378 }
379 Err(ModelLocalError::Provider(error)) => return Err(error),
380 }
381 }
382 unsupported_models.sort_by(|left, right| left.id.cmp(&right.id));
383 let provider_fingerprint = fingerprint(
384 "cookie-agent/dynamic-provider/v1",
385 &(
386 self.registry.revision(),
387 COMPILER_VERSION,
388 catalog_revision,
389 &record.id,
390 models
391 .iter()
392 .map(|(id, model)| (id, &model.behavior_fingerprint))
393 .collect::<Vec<_>>(),
394 &unsupported_models,
395 ),
396 );
397 Ok(CompiledDynamicProvider {
398 id: record.id.clone(),
399 models,
400 unsupported_models,
401 fingerprint: provider_fingerprint,
402 })
403 }
404
405 #[allow(clippy::too_many_arguments)]
406 fn compile_managed_model(
407 &self,
408 catalog_revision: &str,
409 provider_id: &ProviderId,
410 model: &CatalogModelRecord,
411 provider_family: &'static FamilyRecipe,
412 resolved: &crate::recipes::ResolvedFamilyModel,
413 authored: Option<&ModelsDevProvider>,
414 override_: Option<&ManagedModelOverride>,
415 global_headers: &BTreeMap<HeaderName, SafeStaticHeaderValue>,
416 ) -> Result<CompiledDynamicModel, ModelLocalError> {
417 let options =
418 override_.map_or_else(ProviderOptions::default, |value| value.options.resolve());
419 let endpoint_family = if resolved.recipe.family == FamilyKind::OpenAiCompatibleChat {
420 OvenAdapterFamily::OpenaiCompatible
421 } else {
422 resolved.adapter
423 };
424 let adapter = if options.request_endpoint.is_none() {
425 resolved.adapter
426 } else {
427 endpoint_family
428 .with_endpoint(options.request_endpoint)
429 .map_err(ModelLocalError::Provider)?
430 };
431 let adapter_id = match adapter {
432 _ if resolved.recipe.family == FamilyKind::OpenAiCompatibleChat => {
433 format!(
434 "oven.openai-compatible.{}.{}",
435 if adapter == OvenAdapterFamily::OpenaiResponses {
436 "responses"
437 } else {
438 "chat"
439 },
440 provider_id.as_str()
441 )
442 }
443 OvenAdapterFamily::AnthropicCompatible => {
444 format!(
445 "oven.anthropic-compatible.messages.{}",
446 provider_id.as_str()
447 )
448 }
449 _ => adapter.protocol_recipe().to_owned(),
450 };
451 let mut capabilities = capabilities_from_catalog(model, adapter).map_err(|_| {
452 ModelLocalError::Unsupported("unsupported_model_capabilities".to_owned())
453 })?;
454 apply_compaction_config(
455 &mut capabilities,
456 adapter,
457 provider_id,
458 override_.map_or(crate::NativeCompactionConfig::Unsupported, |value| {
459 value.compaction
460 }),
461 )
462 .map_err(ModelLocalError::Provider)?;
463 if !validate_capability_shape(&capabilities)
464 || validate_capability_ceiling(adapter, &capabilities).is_err()
465 {
466 return Err(if override_.is_some() {
467 ModelLocalError::Provider(DynamicCompileError::CustomModel)
468 } else {
469 ModelLocalError::Unsupported("unsupported_model_capabilities; tool_calling = false conflicts with the parallel_tool_calls = true fallback".to_owned())
470 });
471 }
472 let template = authored
473 .and_then(|value| value.base_url.as_ref())
474 .map(crate::authoring::EndpointUrl::as_str)
475 .map(str::to_owned)
476 .or_else(|| resolved.endpoint_template.clone());
477 let (setup, endpoint) = resolved_managed_setup_and_endpoint(
478 provider_family,
479 resolved.recipe.family,
480 template.as_deref(),
481 authored,
482 )?;
483 capabilities.native_replay =
484 automatic_replay(adapter, capabilities.reasoning, setup.as_ref());
485 let wire_model_id = override_
486 .and_then(|value| value.model_id.clone())
487 .map_or_else(
488 || provider_wire_model_id(&model.id, adapter, setup.as_ref()),
489 Ok,
490 )
491 .map_err(ModelLocalError::Provider)?;
492 let required_auth_method = match adapter {
493 OvenAdapterFamily::AwsBedrockConverse => Some("aws-sigv4-credentials-v1"),
494 OvenAdapterFamily::OpenaiResponses if resolved.recipe.family == FamilyKind::Bedrock => {
495 Some("bearer-api-key-v1")
496 }
497 _ => None,
498 };
499 let auth = managed_auth(
500 provider_family,
501 resolved.recipe,
502 required_auth_method,
503 authored,
504 )?;
505 let mut defaults = managed_defaults(model);
506 if let Some(override_) = override_ {
507 override_.defaults.apply(&mut defaults);
508 }
509 if !validate_defaults(&defaults, &capabilities) {
510 return Err(if override_.is_some() {
511 ModelLocalError::Provider(DynamicCompileError::CustomModel)
512 } else {
513 ModelLocalError::Unsupported("unsupported_model_capabilities".to_owned())
514 });
515 }
516 let (mut variants, mut variant_order, default_variant) =
517 managed_variants(&model.reasoning_options, override_, &defaults, &options).map_err(
518 |_| {
519 if override_.is_some() {
520 ModelLocalError::Provider(DynamicCompileError::Variant)
521 } else {
522 ModelLocalError::Unsupported("unsupported_protocol_feature".to_owned())
523 }
524 },
525 )?;
526 if override_.is_none() {
531 variants.retain(|_, variant| reasoning_supported(variant.reasoning.as_ref(), adapter));
532 variant_order.retain(|id| variants.contains_key(id));
533 }
534 if variants
535 .values()
536 .any(|variant| !validate_defaults(&variant.defaults, &capabilities))
537 {
538 return Err(ModelLocalError::Provider(DynamicCompileError::Variant));
539 }
540 let headers = merge_headers([
541 (global_headers, "global".to_owned()),
542 (
543 authored.map_or(&EMPTY_HEADERS, |value| &value.headers),
544 format!("provider `{provider_id}`"),
545 ),
546 (
547 override_.map_or(&EMPTY_HEADERS, |value| &value.headers),
548 format!("provider `{provider_id}` model `{}`", model.id),
549 ),
550 ])
551 .map_err(ModelLocalError::Provider)?;
552 for variant in variants.values_mut() {
553 variant.headers = merge_headers([
554 (
555 &headers,
556 format!("provider `{provider_id}` model `{}`", model.id),
557 ),
558 (
559 &variant.headers,
560 format!(
561 "provider `{provider_id}` model `{}` variant `{}`",
562 model.id, variant.id
563 ),
564 ),
565 ])
566 .map_err(ModelLocalError::Provider)?;
567 }
568 let display_name = override_
569 .and_then(|value| value.display_name.clone())
570 .unwrap_or_else(|| model.name.clone());
571 let status = if setup.is_none() {
572 CompiledModelStatus::SetupUnavailable
573 } else if auth.source == AuthSourceCategory::Unavailable {
574 CompiledModelStatus::CredentialsUnavailable
575 } else {
576 CompiledModelStatus::Available
577 };
578 let behavior_fingerprint = fingerprint(
579 "cookie-agent/dynamic-model-behavior/v1",
580 &(
581 (
582 self.registry.revision(),
583 COMPILER_VERSION,
584 catalog_revision,
585 provider_id,
586 &model.id,
587 &wire_model_id,
588 resolved.recipe.family.id(),
589 &adapter_id,
590 adapter,
591 ),
592 &endpoint,
593 &setup,
594 &auth,
595 &capabilities,
596 &defaults,
597 &options,
598 &headers,
599 &variants,
600 &variant_order,
601 &default_variant,
602 "managed_catalog",
603 ),
604 );
605 let compiled = CompiledDynamicModel {
606 custom: false,
607 id: model.id.clone(),
608 wire_model_id,
609 display_name,
610 family_id: resolved.recipe.family.id().to_owned(),
611 effective_npm: resolved.npm.clone(),
612 adapter_id,
613 resolved_shape: if matches!(
614 adapter,
615 OvenAdapterFamily::OpenaiResponses | OvenAdapterFamily::AzureOpenaiResponses
616 ) {
617 "responses"
618 } else {
619 "chat"
620 }
621 .to_owned(),
622 reasoning_field: match model.interleaved {
623 Some(crate::catalog::CatalogInterleaved::Reasoning) => "reasoning",
624 Some(crate::catalog::CatalogInterleaved::ReasoningContent)
625 | Some(crate::catalog::CatalogInterleaved::Default)
626 | None => "reasoning_content",
627 }
628 .to_owned(),
629 adapter,
630 endpoint,
631 setup,
632 auth,
633 capabilities,
634 replay_declaration: None,
635 defaults,
636 options,
637 headers,
638 cost: model.cost.clone(),
639 variants,
640 variant_order,
641 default_variant,
642 status,
643 behavior_fingerprint,
644 };
645 compiled.validate_settings().map_err(|error| {
646 if override_.is_some() {
647 ModelLocalError::Provider(error)
648 } else {
649 ModelLocalError::Unsupported(error.to_string())
650 }
651 })?;
652 Ok(compiled)
653 }
654
655 pub fn compile_custom(
656 &self,
657 provider_id: &ProviderId,
658 provider: &CustomProvider,
659 ) -> Result<CompiledDynamicProvider, DynamicCompileError> {
660 self.compile_custom_with_headers(provider_id, provider, &BTreeMap::new())
661 }
662
663 pub fn compile_custom_with_headers(
664 &self,
665 provider_id: &ProviderId,
666 provider: &CustomProvider,
667 global_headers: &BTreeMap<HeaderName, SafeStaticHeaderValue>,
668 ) -> Result<CompiledDynamicProvider, DynamicCompileError> {
669 let adapter = OvenAdapterFamily::parse(provider.adaptor.as_str())
670 .ok_or(DynamicCompileError::UnsupportedAdapter)?;
671 crate::authoring::validate_provider_cache(provider.cache.as_ref(), adapter.id())
672 .map_err(DynamicCompileError::Cache)?;
673 validate_custom_endpoint(adapter, &provider.endpoint)
674 .map_err(|_| DynamicCompileError::Endpoint)?;
675 let setup_recipe = custom_setup_recipe(adapter);
676 let setup = validate_setup(setup_recipe, &provider.setup)
677 .map_err(|_| DynamicCompileError::Setup)?;
678 let auth_method = validate_auth_definition(&provider.auth, adapter.allowed_auth_methods())
679 .map_err(|_| DynamicCompileError::Auth)?;
680 let auth = custom_auth_shape(&provider.auth, auth_method);
681 let mut models = BTreeMap::new();
682 for (id, model) in &provider.models {
683 let options = model.options.resolve();
684 let resolved_adapter = adapter.with_endpoint(options.request_endpoint)?;
685 let wire = wire_adapter_for_custom(resolved_adapter);
686 let mut capabilities = model.capabilities.resolve(resolved_adapter);
687 if model.capabilities.native_replay.is_none() {
688 capabilities.native_replay =
689 automatic_replay(resolved_adapter, capabilities.reasoning, Some(&setup));
690 }
691 let defaults = model.defaults.resolve();
692 let wire_model_id = model.model_id.clone().map_or_else(
693 || provider_wire_model_id(id, resolved_adapter, Some(&setup)),
694 Ok,
695 )?;
696 if !validate_capability_shape(&capabilities)
697 || validate_capability_ceiling(resolved_adapter, &capabilities).is_err()
698 || !validate_defaults(&defaults, &capabilities)
699 || !validate_custom_options(&options, resolved_adapter)
700 {
701 return Err(DynamicCompileError::CustomModel);
702 }
703 let (mut variants, variant_order, default_variant) = custom_variants(
704 &model.variants,
705 model.default_variant.as_ref(),
706 &defaults,
707 &options,
708 )
709 .map_err(|_| DynamicCompileError::Variant)?;
710 let headers = merge_headers([
711 (global_headers, "global".to_owned()),
712 (&provider.headers, format!("provider `{provider_id}`")),
713 (
714 &model.headers,
715 format!("provider `{provider_id}` model `{id}`"),
716 ),
717 ])?;
718 for variant in variants.values_mut() {
719 variant.headers = merge_headers([
720 (&headers, format!("provider `{provider_id}` model `{id}`")),
721 (
722 &variant.headers,
723 format!(
724 "provider `{provider_id}` model `{id}` variant `{}`",
725 variant.id
726 ),
727 ),
728 ])?;
729 }
730 if !model.enabled {
731 continue;
732 }
733 let endpoint = provider.endpoint.as_str().trim_end_matches('/').to_owned();
734 let safe_headers = headers
735 .iter()
736 .map(|(name, value)| (name.as_str(), value.as_str()))
737 .collect::<Vec<_>>();
738 let behavior_fingerprint = fingerprint(
739 "cookie-agent/custom-model-behavior/v1",
740 &(
741 self.registry.revision(),
742 COMPILER_VERSION,
743 provider_id,
744 id,
745 wire.adapter_id,
746 adapter,
747 &endpoint,
748 &setup,
749 &auth,
750 &safe_headers,
751 &capabilities,
752 &model.defaults,
753 &options,
754 (&variants, &variant_order, &default_variant, &wire_model_id),
755 "custom_authored",
756 ),
757 );
758 models.insert(
759 id.clone(),
760 CompiledDynamicModel {
761 custom: true,
762 id: id.clone(),
763 wire_model_id,
764 display_name: model.display_name.clone(),
765 family_id: "custom".into(),
766 effective_npm: "custom".into(),
767 adapter_id: if adapter == OvenAdapterFamily::OpenaiCompatible {
768 format!(
769 "oven.openai-compatible.{}.{}",
770 if resolved_adapter == OvenAdapterFamily::OpenaiResponses {
771 "responses"
772 } else {
773 "chat"
774 },
775 provider_id
776 )
777 } else {
778 wire.adapter_id.into()
779 },
780 resolved_shape: if resolved_adapter == OvenAdapterFamily::OpenaiResponses
781 || resolved_adapter == OvenAdapterFamily::AzureOpenaiResponses
782 {
783 "responses"
784 } else {
785 "chat"
786 }
787 .into(),
788 reasoning_field: "reasoning_content".into(),
789 adapter: resolved_adapter,
790 endpoint: Some(endpoint),
791 setup: Some(setup.clone()),
792 auth: auth.clone(),
793 capabilities,
794 replay_declaration: model.capabilities.native_replay,
795 defaults,
796 options,
797 headers,
798 cost: None,
799 variants,
800 variant_order,
801 default_variant,
802 status: CompiledModelStatus::Available,
803 behavior_fingerprint,
804 },
805 );
806 models[id].validate_settings()?;
807 }
808 let safe_headers = provider
809 .headers
810 .iter()
811 .map(|(name, value)| (name.as_str(), value.as_str()))
812 .collect::<Vec<_>>();
813 let provider_fingerprint = fingerprint(
814 "cookie-agent/custom-provider/v1",
815 &(
816 self.registry.revision(),
817 COMPILER_VERSION,
818 provider_id,
819 provider.endpoint.as_str(),
820 adapter,
821 &setup,
822 &auth,
823 &safe_headers,
824 models
825 .iter()
826 .map(|(id, model)| (id, &model.behavior_fingerprint))
827 .collect::<Vec<_>>(),
828 ),
829 );
830 Ok(CompiledDynamicProvider {
831 id: provider_id.clone(),
832 models,
833 unsupported_models: Vec::new(),
834 fingerprint: provider_fingerprint,
835 })
836 }
837}
838
839static EMPTY_HEADERS: BTreeMap<HeaderName, SafeStaticHeaderValue> = BTreeMap::new();
840
841fn merge_headers<'a>(
842 layers: impl IntoIterator<Item = (&'a BTreeMap<HeaderName, SafeStaticHeaderValue>, String)>,
843) -> Result<BTreeMap<HeaderName, SafeStaticHeaderValue>, DynamicCompileError> {
844 let mut merged = BTreeMap::new();
845 for (layer, scope) in layers {
846 validate_header_ownership(layer, scope).map_err(DynamicCompileError::StaticHeaders)?;
847 for (name, value) in layer {
848 if value.as_str().is_empty() {
849 merged.remove(name);
850 } else {
851 merged.insert(name.clone(), value.clone());
852 }
853 }
854 }
855 validate_header_limits(&merged).map_err(DynamicCompileError::StaticHeaders)?;
856 Ok(merged)
857}
858
859pub(crate) fn validate_managed_cache(
860 record: &CatalogProviderRecord,
861 authored: Option<&ModelsDevProvider>,
862 family: &'static FamilyRecipe,
863) -> Result<(), DynamicCompileError> {
864 let Some(authored) = authored else {
865 return Ok(());
866 };
867 let Some(cache) = authored.cache.as_ref() else {
868 return Ok(());
869 };
870 cache.validate_supported_shape().map_err(|error| {
871 DynamicCompileError::Cache(format!("provider `{}`: {error}", record.id))
872 })?;
873
874 let mut adapters = BTreeSet::from([managed_provider_adapter(
875 family.family,
876 None,
877 record.shape.as_deref(),
878 )]);
879 for (model_id, entry) in &record.models {
880 let Some(model) = entry.record.as_ref() else {
881 continue;
882 };
883 let override_ = authored.model_overrides.get(model_id);
884 if let Ok(resolved) = resolve_model(record, model, None, None) {
885 let adapter = resolved
886 .adapter
887 .with_endpoint(override_.and_then(|value| value.options.request_endpoint))?;
888 adapters.insert(
889 if resolved.recipe.family == FamilyKind::OpenAiCompatibleChat {
890 OvenAdapterFamily::OpenaiCompatible
891 } else {
892 adapter
893 },
894 );
895 }
896 }
897 for adapter in adapters {
898 crate::authoring::validate_provider_cache(Some(cache), adapter.id()).map_err(|error| {
899 DynamicCompileError::Cache(format!("provider `{}`: {error}", record.id))
900 })?;
901 }
902 Ok(())
903}
904
905pub(crate) fn managed_provider_adapter(
906 family: FamilyKind,
907 authored_shape: Option<crate::ManagedModelShape>,
908 catalog_shape: Option<&str>,
909) -> OvenAdapterFamily {
910 let responses = matches!(authored_shape, Some(crate::ManagedModelShape::Responses))
911 || authored_shape.is_none() && catalog_shape == Some("responses")
912 || authored_shape.is_none() && catalog_shape.is_none() && family == FamilyKind::OpenAi;
913 match family {
914 FamilyKind::OpenAiCompatibleChat => OvenAdapterFamily::OpenaiCompatible,
915 FamilyKind::Anthropic => OvenAdapterFamily::AnthropicCompatible,
916 FamilyKind::OpenAi if responses => OvenAdapterFamily::OpenaiResponses,
917 FamilyKind::OpenAi => OvenAdapterFamily::OpenaiChat,
918 FamilyKind::Google => OvenAdapterFamily::GoogleGemini,
919 FamilyKind::Vertex | FamilyKind::VertexAnthropic => OvenAdapterFamily::GoogleVertexGemini,
920 FamilyKind::Bedrock if responses => OvenAdapterFamily::OpenaiResponses,
921 FamilyKind::Bedrock => OvenAdapterFamily::AwsBedrockConverse,
922 FamilyKind::Azure if responses => OvenAdapterFamily::AzureOpenaiResponses,
923 FamilyKind::Azure => OvenAdapterFamily::AzureOpenaiChat,
924 FamilyKind::Cohere => OvenAdapterFamily::CohereV2Chat,
925 }
926}
927
928fn apply_compaction_config(
929 capabilities: &mut ModelCapabilities,
930 adapter: OvenAdapterFamily,
931 provider_id: &ProviderId,
932 config: crate::NativeCompactionConfig,
933) -> Result<(), DynamicCompileError> {
934 capabilities.compaction = match (adapter, config) {
935 (_, crate::NativeCompactionConfig::Unsupported) => crate::CompactionCapability::Unsupported,
936 (
937 OvenAdapterFamily::OpenaiResponses,
938 crate::NativeCompactionConfig::OpenAiResponsesCompact,
939 ) if provider_id.as_str() == "openai" => crate::CompactionCapability::Native,
940 (
941 OvenAdapterFamily::AzureOpenaiResponses,
942 crate::NativeCompactionConfig::AzureResponsesCompact,
943 ) if provider_id.as_str() == "azure.openai" => crate::CompactionCapability::Native,
944 _ => return Err(DynamicCompileError::CustomModel),
945 };
946 Ok(())
947}
948
949enum ModelLocalError {
950 Unsupported(String),
951 Provider(DynamicCompileError),
952}
953
954fn resolved_managed_setup_and_endpoint(
955 _provider_family: &'static FamilyRecipe,
956 family: FamilyKind,
957 template: Option<&str>,
958 authored: Option<&ModelsDevProvider>,
959) -> Result<(Option<ValidatedSetup>, Option<String>), ModelLocalError> {
960 if template.is_none() && !matches!(family, FamilyKind::Vertex | FamilyKind::VertexAnthropic) {
961 return Ok((None, None));
962 }
963 let input = authored
964 .map(|value| &value.setup)
965 .cloned()
966 .unwrap_or_default();
967 let mut values = BTreeMap::new();
968 for (id, value) in &input {
969 let crate::authoring::SafeSetupValue::String(value) = value else {
970 return Err(ModelLocalError::Provider(DynamicCompileError::Setup));
971 };
972 values.insert(id.as_str().to_owned(), value.as_str().to_owned());
973 }
974 let mut required = template
975 .map(placeholders)
976 .unwrap_or_default()
977 .into_iter()
978 .map(|name| crate::recipes::setup_field_name(&name))
979 .collect::<Vec<_>>();
980 match family {
981 FamilyKind::Vertex | FamilyKind::VertexAnthropic => {
982 required.extend(["project".into(), "location".into()])
983 }
984 FamilyKind::Bedrock => required.push("region".into()),
985 FamilyKind::Azure => required.push("resource_name".into()),
986 _ => {}
987 }
988 required.sort();
989 required.dedup();
990 if required.iter().any(|field| !values.contains_key(field)) {
991 return Ok((None, None));
992 }
993 let endpoint = template
994 .and_then(|template| substitute_placeholders(template, &values))
995 .map(|value| value.trim_end_matches('/').to_owned())
996 .or_else(|| match family {
997 FamilyKind::Vertex | FamilyKind::VertexAnthropic => Some(format!(
998 "https://{}-aiplatform.googleapis.com/v1/projects/{}/locations/{}",
999 values.get("location")?,
1000 values.get("project")?,
1001 values.get("location")?
1002 )),
1003 _ => None,
1004 });
1005 let setup = ValidatedSetup {
1006 recipe_id: "family-derived-setup-v1",
1007 values,
1008 };
1009 Ok((Some(setup), endpoint))
1010}
1011
1012fn managed_auth(
1013 provider_family: &FamilyRecipe,
1014 effective_recipe: &FamilyRecipe,
1015 required_method: Option<&'static str>,
1016 authored: Option<&ModelsDevProvider>,
1017) -> Result<CompiledAuthShape, ModelLocalError> {
1018 if let Some(authored) = authored {
1019 if authored.api_key.is_some() {
1020 let source_method = provider_family.default_auth_method;
1021 let target_method =
1022 compatible_model_auth(source_method, effective_recipe, required_method);
1023 let Some(method) = target_method.and_then(auth_method) else {
1024 return Ok(auth_shape(
1025 auth_method(effective_recipe.default_auth_method)
1026 .ok_or(ModelLocalError::Provider(DynamicCompileError::Auth))?,
1027 BTreeMap::new(),
1028 AuthSourceCategory::Unavailable,
1029 ));
1030 };
1031 let required_api_key = method.credentials.len() == 1
1032 && method.credentials[0].required
1033 && method.credentials[0].name == "api_key";
1034 if !required_api_key {
1035 return Err(ModelLocalError::Provider(DynamicCompileError::Auth));
1036 }
1037 return Ok(auth_shape(
1038 method,
1039 BTreeMap::new(),
1040 AuthSourceCategory::AuthoredApiKey,
1041 ));
1042 }
1043 if let Some(auth) = &authored.auth_override {
1044 let target_method =
1045 compatible_model_auth(auth.method.as_str(), effective_recipe, required_method);
1046 let Some(method) = target_method.and_then(auth_method) else {
1047 return Ok(auth_shape(
1048 auth_method(effective_recipe.default_auth_method)
1049 .ok_or(ModelLocalError::Provider(DynamicCompileError::Auth))?,
1050 BTreeMap::new(),
1051 AuthSourceCategory::Unavailable,
1052 ));
1053 };
1054 return Ok(auth_shape(
1055 method,
1056 BTreeMap::new(),
1057 AuthSourceCategory::AuthoredOverride,
1058 ));
1059 }
1060 }
1061 let method = auth_method(required_method.unwrap_or(effective_recipe.default_auth_method))
1062 .ok_or(ModelLocalError::Provider(DynamicCompileError::Auth))?;
1063 Ok(auth_shape(
1064 method,
1065 BTreeMap::new(),
1066 AuthSourceCategory::Unavailable,
1067 ))
1068}
1069
1070fn compatible_model_auth(
1071 source_method: &str,
1072 effective_recipe: &FamilyRecipe,
1073 required_method: Option<&'static str>,
1074) -> Option<&'static str> {
1075 let mapped = crate::recipes::compatible_auth_method(source_method, effective_recipe)?;
1076 required_method.map_or(Some(mapped), |required| {
1077 (mapped == required).then_some(required)
1078 })
1079}
1080
1081fn custom_auth_shape(
1082 auth: &AuthDefinition,
1083 method: &'static crate::recipes::AuthMethodRecipe,
1084) -> CompiledAuthShape {
1085 let parameters = auth
1086 .parameters
1087 .iter()
1088 .map(|(name, value)| (name.as_str().to_owned(), value.as_str().to_owned()))
1089 .collect();
1090 auth_shape(method, parameters, AuthSourceCategory::AuthoredCustom)
1091}
1092
1093fn auth_shape(
1094 method: &'static crate::recipes::AuthMethodRecipe,
1095 safe_parameters: BTreeMap<String, String>,
1096 source: AuthSourceCategory,
1097) -> CompiledAuthShape {
1098 let credential_fields = method
1099 .credentials
1100 .iter()
1101 .map(|field| field.name.to_owned())
1102 .collect();
1103 let mut owned_headers = method
1104 .owned_headers
1105 .iter()
1106 .map(|value| (*value).to_owned())
1107 .collect::<BTreeSet<_>>();
1108 if method.id == "api-key-header-v1"
1109 && let Some(header) = safe_parameters.get("header_name")
1110 {
1111 owned_headers.insert(header.clone());
1112 }
1113 CompiledAuthShape {
1114 method: method.id.to_owned(),
1115 safe_parameters,
1116 credential_fields,
1117 owned_headers: owned_headers.into_iter().collect(),
1118 source,
1119 }
1120}
1121
1122fn validate_custom_options(options: &ProviderOptions, adapter: OvenAdapterFamily) -> bool {
1123 let has_openai =
1124 options.organization.is_some() || options.project.is_some() || options.store.is_some();
1125 let has_anthropic = !options.beta.is_empty();
1126 let has_compatible = options.request_endpoint.is_some()
1127 && !matches!(
1128 adapter,
1129 OvenAdapterFamily::OpenaiChat
1130 | OvenAdapterFamily::OpenaiResponses
1131 | OvenAdapterFamily::OpenaiCompatible
1132 | OvenAdapterFamily::AzureOpenaiChat
1133 | OvenAdapterFamily::AzureOpenaiResponses
1134 );
1135 let has_setup_leak = options.api_version.is_some()
1136 || options.location.is_some()
1137 || options.region.is_some()
1138 || options.deployment.is_some();
1139 !has_setup_leak
1140 && match adapter {
1141 OvenAdapterFamily::Anthropic | OvenAdapterFamily::AnthropicCompatible => {
1142 !has_openai && !has_compatible
1143 }
1144 OvenAdapterFamily::OpenaiChat => {
1145 !has_anthropic && !has_compatible && options.store.is_none()
1146 }
1147 OvenAdapterFamily::OpenaiResponses => !has_anthropic && !has_compatible,
1148 OvenAdapterFamily::OpenaiCompatible => !has_anthropic && !has_openai,
1149 OvenAdapterFamily::GoogleGemini
1150 | OvenAdapterFamily::GoogleVertexGemini
1151 | OvenAdapterFamily::AwsBedrockConverse
1152 | OvenAdapterFamily::AzureOpenaiChat
1153 | OvenAdapterFamily::AzureOpenaiResponses
1154 | OvenAdapterFamily::CohereV2Chat => !has_anthropic && !has_openai && !has_compatible,
1155 }
1156}
1157
1158fn reasoning_supported(
1159 reasoning: Option<&crate::ReasoningBehavior>,
1160 adapter: OvenAdapterFamily,
1161) -> bool {
1162 match reasoning {
1163 None => true,
1164 Some(crate::ReasoningBehavior::Effort { .. }) => adapter != OvenAdapterFamily::CohereV2Chat,
1165 Some(
1166 crate::ReasoningBehavior::Toggle { .. } | crate::ReasoningBehavior::BudgetTokens { .. },
1167 ) => matches!(
1168 adapter,
1169 OvenAdapterFamily::Anthropic
1170 | OvenAdapterFamily::AnthropicCompatible
1171 | OvenAdapterFamily::AwsBedrockConverse
1172 | OvenAdapterFamily::GoogleGemini
1173 | OvenAdapterFamily::GoogleVertexGemini
1174 | OvenAdapterFamily::CohereV2Chat
1175 ),
1176 }
1177}