Skip to main content

cookie_agent_models/compiler/
dynamic.rs

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    /// Whether this model comes from an authored custom provider
61    /// (`source = "custom"`). Replay identity is attributed per provider for
62    /// custom Responses providers so separate gateways never share history.
63    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        // Catalog reasoning controls can exceed the selected wire protocol.
527        // Keep supported generated choices without losing the whole model.
528        // Explicit model overrides remain strict and must disable or replace
529        // incompatible variants themselves.
530        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}