1use std::{
6 cmp::Ordering,
7 collections::{BTreeMap, BTreeSet},
8 sync::Arc,
9};
10
11use cookie_agent_identity::ModelSnapshotRevision;
12use serde_json::Value;
13use sha2::{Digest as _, Sha256};
14use thiserror::Error;
15
16use crate::secure_store::SecureStoreError;
17
18pub use cookie_agent_protocol::{
19 CompiledSafeModelBlueprint, FrozenAuthParameterValue, FrozenCredentialBinding,
20 FrozenCredentialSource, FrozenModelBinding, FrozenProviderOptions, FrozenProviderSource,
21 FrozenRequestDefaults, FrozenResolvedRequestDefaults, FrozenSetupBinding,
22 FrozenVariantBlueprint, HeaderName, ModelSnapshotManifestSchemaVersion,
23 ModelSnapshotManifestV1, ModelSnapshotPayloadV1, NormalizedDecimal, SafeEndpointIdentity,
24 SafeStaticHeaderValue, Sha256Digest,
25};
26
27pub const MODEL_SNAPSHOT_MAX_BYTES: u64 = 4 * 1024 * 1024;
29pub const MODEL_SNAPSHOT_MAX_FILES: usize = 4096;
31
32const MAX_IJSON_INTEGER: u64 = 9_007_199_254_740_991;
33const MAX_JSON_DEPTH: usize = 64;
34const MAX_JSON_ITEMS: usize = 1_000_000;
35
36pub fn build_manifest(
40 mut payload: ModelSnapshotPayloadV1,
41) -> Result<Arc<ModelSnapshotManifestV1>, ManifestError> {
42 normalize_payload(&mut payload)?;
43 validate_payload(&payload)?;
44 let canonical = canonical_payload_bytes(&payload)?;
45 if canonical.len() as u64 > MODEL_SNAPSHOT_MAX_BYTES {
46 return Err(ManifestError::InvalidModelSnapshotManifest);
47 }
48 let digest = sha256_hex(&canonical);
49 let revision = ModelSnapshotRevision::new(format!("sha256:{digest}"))
50 .map_err(|_| ManifestError::InvalidModelSnapshotManifest)?;
51 Ok(Arc::new(ModelSnapshotManifestV1 {
52 schema_version: ModelSnapshotManifestSchemaVersion::current(),
53 revision,
54 payload,
55 }))
56}
57
58pub fn canonical_payload_bytes(payload: &ModelSnapshotPayloadV1) -> Result<Vec<u8>, ManifestError> {
60 let value =
61 serde_json::to_value(payload).map_err(|_| ManifestError::InvalidModelSnapshotManifest)?;
62 validate_json_value(&value, 0, &mut 0)?;
63 let mut output = Vec::new();
64 write_jcs(&value, &mut output)?;
65 Ok(output)
66}
67
68pub fn blueprint_fingerprint(
70 blueprint: &CompiledSafeModelBlueprint,
71) -> Result<Sha256Digest, ManifestError> {
72 let mut value =
73 serde_json::to_value(blueprint).map_err(|_| ManifestError::InvalidModelSnapshotManifest)?;
74 value
75 .as_object_mut()
76 .ok_or(ManifestError::InvalidModelSnapshotManifest)?
77 .remove("blueprint_fingerprint");
78 validate_json_value(&value, 0, &mut 0)?;
79 let mut canonical = Vec::new();
80 write_jcs(&value, &mut canonical)?;
81 let mut hasher = Sha256::new();
82 hasher.update(b"cookie-agent/model-blueprint/v1\0");
83 hasher.update(canonical);
84 Sha256Digest::new(format!("{:x}", hasher.finalize()))
85 .map_err(|_| ManifestError::InvalidModelSnapshotManifest)
86}
87
88pub struct FrozenBehaviorRef<'a> {
89 pub descriptor: &'a oven_sdk::LanguageModelDescriptor,
90 pub defaults: &'a FrozenResolvedRequestDefaults,
91 pub options: &'a FrozenProviderOptions,
92 pub static_headers: &'a BTreeMap<HeaderName, SafeStaticHeaderValue>,
93 pub behavior_fingerprint: &'a Sha256Digest,
94 pub selection_fingerprint: &'a Sha256Digest,
95}
96
97#[must_use]
98pub fn selected_behavior<'a>(
99 blueprint: &'a CompiledSafeModelBlueprint,
100 selection: &cookie_agent_identity::ModelSelection,
101) -> Option<FrozenBehaviorRef<'a>> {
102 if selection.model != blueprint.selection.model {
103 return None;
104 }
105 match selection.variant.as_ref() {
106 None => Some(FrozenBehaviorRef {
107 descriptor: &blueprint.descriptor,
108 defaults: &blueprint.defaults,
109 options: &blueprint.options,
110 static_headers: &blueprint.static_headers,
111 behavior_fingerprint: &blueprint.behavior_fingerprint,
112 selection_fingerprint: &blueprint.selection_fingerprint,
113 }),
114 Some(id) => blueprint
115 .variants
116 .iter()
117 .find(|variant| &variant.id == id)
118 .map(|variant| FrozenBehaviorRef {
119 descriptor: &variant.descriptor,
120 defaults: &variant.defaults,
121 options: &variant.options,
122 static_headers: &variant.static_headers,
123 behavior_fingerprint: &variant.behavior_fingerprint,
124 selection_fingerprint: &variant.selection_fingerprint,
125 }),
126 }
127}
128
129pub fn behavior_fingerprint(
130 blueprint: &CompiledSafeModelBlueprint,
131 selection: &cookie_agent_identity::ModelSelection,
132) -> Result<Sha256Digest, ManifestError> {
133 let behavior = selected_behavior(blueprint, selection)
134 .ok_or(ManifestError::InvalidModelSnapshotManifest)?;
135 hash_canonical(
136 b"cookie-agent/model-behavior/v1\0",
137 &serde_json::json!({
138 "selection": selection,
139 "source": blueprint.source,
140 "config_override_fingerprint": blueprint.config_override_fingerprint,
141 "setup_binding": blueprint.setup_binding,
142 "credential_binding": blueprint.credential_binding,
143 "endpoint_identity": blueprint.endpoint_identity,
144 "provider_recipe": blueprint.provider_recipe,
145 "protocol_recipe": blueprint.protocol_recipe,
146 "setup_recipe": blueprint.setup_recipe,
147 "auth_method": blueprint.auth_method,
148 "compiler_version": blueprint.compiler_version,
149 "descriptor": behavior.descriptor,
150 "defaults": behavior.defaults,
151 "options": behavior.options,
152 "static_headers": behavior.static_headers,
153 }),
154 )
155}
156
157pub fn selection_fingerprint(
158 blueprint: &CompiledSafeModelBlueprint,
159 selection: &cookie_agent_identity::ModelSelection,
160) -> Result<Sha256Digest, ManifestError> {
161 let behavior = selected_behavior(blueprint, selection)
162 .ok_or(ManifestError::InvalidModelSnapshotManifest)?;
163 hash_canonical(
164 b"cookie-agent/model-selection/v1\0",
165 &serde_json::json!({
166 "selection": selection,
167 "descriptor": behavior.descriptor,
168 "defaults": behavior.defaults,
169 "options": behavior.options,
170 "behavior_fingerprint": behavior.behavior_fingerprint,
171 }),
172 )
173}
174
175pub fn frozen_binding(
176 manifest_revision: ModelSnapshotRevision,
177 blueprint: &CompiledSafeModelBlueprint,
178 selection: cookie_agent_identity::ModelSelection,
179) -> Result<FrozenModelBinding, ManifestError> {
180 let behavior = selected_behavior(blueprint, &selection)
181 .ok_or(ManifestError::InvalidModelSnapshotManifest)?;
182 let binding = FrozenModelBinding {
183 manifest_revision,
184 blueprint_fingerprint: blueprint.blueprint_fingerprint.clone(),
185 selection,
186 source: blueprint.source.clone(),
187 config_override_fingerprint: blueprint.config_override_fingerprint.clone(),
188 credential_binding: blueprint.credential_binding.clone(),
189 setup_binding: blueprint.setup_binding.clone(),
190 endpoint_identity: blueprint.endpoint_identity.clone(),
191 provider_recipe: blueprint.provider_recipe.clone(),
192 protocol_recipe: blueprint.protocol_recipe.clone(),
193 setup_recipe: blueprint.setup_recipe.clone(),
194 compiler_version: blueprint.compiler_version.clone(),
195 descriptor: behavior.descriptor.clone(),
196 defaults: behavior.defaults.clone(),
197 options: behavior.options.clone(),
198 static_headers: behavior.static_headers.clone(),
199 behavior_fingerprint: behavior.behavior_fingerprint.clone(),
200 selection_fingerprint: behavior.selection_fingerprint.clone(),
201 };
202 binding
203 .validate()
204 .map_err(|_| ManifestError::InvalidModelSnapshotManifest)?;
205 Ok(binding)
206}
207
208fn hash_canonical(domain: &[u8], value: &Value) -> Result<Sha256Digest, ManifestError> {
209 validate_json_value(value, 0, &mut 0)?;
210 let mut canonical = Vec::new();
211 write_jcs(value, &mut canonical)?;
212 let mut hasher = Sha256::new();
213 hasher.update(domain);
214 hasher.update(canonical);
215 Sha256Digest::new(format!("{:x}", hasher.finalize()))
216 .map_err(|_| ManifestError::InvalidModelSnapshotManifest)
217}
218
219pub(crate) fn canonical_state_fingerprint(
220 domain: &[u8],
221 value: &impl serde::Serialize,
222) -> Result<crate::Sha256Digest, ManifestError> {
223 let value =
224 serde_json::to_value(value).map_err(|_| ManifestError::InvalidModelSnapshotManifest)?;
225 let digest = hash_canonical(domain, &value)?;
226 crate::Sha256Digest::new(digest.as_str().to_owned())
227 .map_err(|_| ManifestError::InvalidModelSnapshotManifest)
228}
229
230fn normalize_payload(payload: &mut ModelSnapshotPayloadV1) -> Result<(), ManifestError> {
231 for blueprint in &mut payload.blueprints {
232 blueprint
233 .credential_binding
234 .fields
235 .sort_by(|left, right| left.as_str().cmp(right.as_str()));
236 blueprint.credential_binding.owned_headers.sort();
237 blueprint
238 .variants
239 .sort_by(|left, right| left.id.cmp(&right.id));
240 let base_selection = blueprint.selection.clone();
241 blueprint.behavior_fingerprint = behavior_fingerprint(blueprint, &base_selection)?;
242 blueprint.selection_fingerprint = selection_fingerprint(blueprint, &base_selection)?;
243 for index in 0..blueprint.variants.len() {
244 let selection = cookie_agent_identity::ModelSelection {
245 model: blueprint.selection.model.clone(),
246 variant: Some(blueprint.variants[index].id.clone()),
247 };
248 let behavior = behavior_fingerprint(blueprint, &selection)?;
249 blueprint.variants[index].behavior_fingerprint = behavior;
250 let selection_fingerprint = selection_fingerprint(blueprint, &selection)?;
251 blueprint.variants[index].selection_fingerprint = selection_fingerprint;
252 }
253 blueprint.blueprint_fingerprint = blueprint_fingerprint(blueprint)?;
254 }
255 payload.blueprints.sort_by(|left, right| {
256 left.selection
257 .model
258 .cmp(&right.selection.model)
259 .then_with(|| left.selection.variant.cmp(&right.selection.variant))
260 });
261 Ok(())
262}
263
264fn validate_payload(payload: &ModelSnapshotPayloadV1) -> Result<(), ManifestError> {
265 if payload.blueprints.len() > MODEL_SNAPSHOT_MAX_FILES {
266 return Err(ManifestError::InvalidModelSnapshotManifest);
267 }
268 let mut models = BTreeSet::new();
269 let mut fingerprints = BTreeSet::new();
270 for blueprint in &payload.blueprints {
271 if !models.insert(blueprint.selection.model.clone())
272 || !fingerprints.insert(blueprint.blueprint_fingerprint.as_str().to_owned())
273 || blueprint.credential_binding.fields.len() > 32
274 || blueprint
275 .credential_binding
276 .fields
277 .windows(2)
278 .any(|pair| pair[0] >= pair[1])
279 || blueprint.credential_binding.owned_headers.len() > 32
280 || blueprint
281 .credential_binding
282 .owned_headers
283 .windows(2)
284 .any(|pair| pair[0] >= pair[1])
285 || blueprint.selection.variant.is_some()
286 || blueprint.setup_recipe != blueprint.setup_binding.setup_recipe
287 || blueprint.auth_method != blueprint.credential_binding.auth_method
288 || matches!(
289 &blueprint.source,
290 FrozenProviderSource::Managed { provider_recipe, .. }
291 if provider_recipe != &blueprint.provider_recipe
292 )
293 || blueprint.descriptor.identity.provider_id.as_str()
294 != blueprint.selection.model.provider_id().as_str()
295 || blueprint.descriptor.identity.model_id.as_str()
296 != blueprint.selection.model.model_id().as_str()
297 || blueprint.defaults.validate().is_err()
298 || blueprint.variants.len() > 256
299 || blueprint
300 .variants
301 .windows(2)
302 .any(|pair| pair[0].id >= pair[1].id)
303 || blueprint.variants.iter().any(|variant| {
304 variant.descriptor.identity != blueprint.descriptor.identity
305 || variant.defaults.validate().is_err()
306 })
307 || behavior_fingerprint(blueprint, &blueprint.selection)?
308 != blueprint.behavior_fingerprint
309 || selection_fingerprint(blueprint, &blueprint.selection)?
310 != blueprint.selection_fingerprint
311 || blueprint.variants.iter().any(|variant| {
312 let selection = cookie_agent_identity::ModelSelection {
313 model: blueprint.selection.model.clone(),
314 variant: Some(variant.id.clone()),
315 };
316 behavior_fingerprint(blueprint, &selection)
317 .map_or(true, |value| value != variant.behavior_fingerprint)
318 || selection_fingerprint(blueprint, &selection)
319 .map_or(true, |value| value != variant.selection_fingerprint)
320 })
321 || blueprint_fingerprint(blueprint)? != blueprint.blueprint_fingerprint
322 {
323 return Err(ManifestError::InvalidModelSnapshotManifest);
324 }
325 }
326 Ok(())
327}
328
329fn validate_json_value(
330 value: &Value,
331 depth: usize,
332 items: &mut usize,
333) -> Result<(), ManifestError> {
334 if depth > MAX_JSON_DEPTH {
335 return Err(ManifestError::InvalidModelSnapshotManifest);
336 }
337 *items = items
338 .checked_add(1)
339 .ok_or(ManifestError::InvalidModelSnapshotManifest)?;
340 if *items > MAX_JSON_ITEMS {
341 return Err(ManifestError::InvalidModelSnapshotManifest);
342 }
343 match value {
344 Value::Number(number) => {
345 let valid = number
346 .as_i64()
347 .is_some_and(|value| value.unsigned_abs() <= MAX_IJSON_INTEGER)
348 || number
349 .as_u64()
350 .is_some_and(|value| value <= MAX_IJSON_INTEGER);
351 if !valid {
352 return Err(ManifestError::InvalidModelSnapshotManifest);
353 }
354 }
355 Value::Array(values) => {
356 for value in values {
357 validate_json_value(value, depth + 1, items)?;
358 }
359 }
360 Value::Object(values) => {
361 for value in values.values() {
362 validate_json_value(value, depth + 1, items)?;
363 }
364 }
365 Value::String(_) => {}
366 Value::Null | Value::Bool(_) => {}
367 }
368 Ok(())
369}
370
371fn write_jcs(value: &Value, output: &mut Vec<u8>) -> Result<(), ManifestError> {
372 match value {
373 Value::Null => output.extend_from_slice(b"null"),
374 Value::Bool(true) => output.extend_from_slice(b"true"),
375 Value::Bool(false) => output.extend_from_slice(b"false"),
376 Value::Number(number) => output.extend_from_slice(number.to_string().as_bytes()),
377 Value::String(value) => output.extend_from_slice(
378 serde_json::to_string(value)
379 .map_err(|_| ManifestError::InvalidModelSnapshotManifest)?
380 .as_bytes(),
381 ),
382 Value::Array(values) => {
383 output.push(b'[');
384 for (index, value) in values.iter().enumerate() {
385 if index > 0 {
386 output.push(b',');
387 }
388 write_jcs(value, output)?;
389 }
390 output.push(b']');
391 }
392 Value::Object(values) => {
393 output.push(b'{');
394 let mut entries = values.iter().collect::<Vec<_>>();
395 entries.sort_by(|(left, _), (right, _)| compare_utf16(left, right));
396 for (index, (key, value)) in entries.into_iter().enumerate() {
397 if index > 0 {
398 output.push(b',');
399 }
400 output.extend_from_slice(
401 serde_json::to_string(key)
402 .map_err(|_| ManifestError::InvalidModelSnapshotManifest)?
403 .as_bytes(),
404 );
405 output.push(b':');
406 write_jcs(value, output)?;
407 }
408 output.push(b'}');
409 }
410 }
411 Ok(())
412}
413
414fn compare_utf16(left: &str, right: &str) -> Ordering {
415 left.encode_utf16().cmp(right.encode_utf16())
416}
417
418fn sha256_hex(bytes: &[u8]) -> String {
419 format!("{:x}", Sha256::digest(bytes))
420}
421
422#[derive(Debug, Error)]
423pub enum ManifestError {
424 #[error("invalid_model_snapshot_manifest")]
425 InvalidModelSnapshotManifest,
426 #[error("model_snapshot_digest_mismatch")]
427 ModelSnapshotDigestMismatch,
428 #[error("missing_model_snapshot_manifest")]
429 MissingModelSnapshotManifest,
430 #[error("model snapshot storage failed")]
431 Storage(#[from] SecureStoreError),
432}