1use serde::{Deserialize, Serialize};
35
36pub const CODEC_CLIENT_VERSION: &str = "0.4";
39
40pub const CODEC_CLIENT_VERSION_HEADER: &str = "Codec-Client-Version";
42
43pub const CODEC_MIN_VERSION_HEADER: &str = "Codec-Min-Version";
45
46pub const CODEC_REQUIRED_FEATURES_HEADER: &str = "Codec-Required-Features";
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct CodecVersionRequiredBody {
52 pub error: String,
53 pub minimum_version: String,
54 pub required_features: Vec<String>,
55 pub client_version: String,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub docs_url: Option<String>,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub deployment_id: Option<String>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct CodecVersionPolicyDocument {
65 pub minimum_version: String,
66 pub required_features: Vec<String>,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub deployment_id: Option<String>,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub docs_url: Option<String>,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub valid_until: Option<String>,
73}
74
75#[derive(Debug, thiserror::Error)]
77pub enum VersionSignalingError {
78 #[error("Codec server requires v{minimum_version}{features_suffix}; this client speaks v{client_version}.{docs_suffix}")]
79 VersionRequired {
80 minimum_version: String,
81 client_version: String,
82 required_features: Vec<String>,
83 features_suffix: String,
84 docs_url: Option<String>,
85 docs_suffix: String,
86 deployment_id: Option<String>,
87 },
88
89 #[error("Codec server returned 426 Upgrade Required but body was not JSON: {0}")]
90 NonJsonBody(String),
91
92 #[error("Codec server returned 426 Upgrade Required with an unrecognized body: {0}")]
93 UnrecognizedBody(String),
94
95 #[error("version-policy doc is malformed: {0}")]
96 MalformedPolicyDoc(String),
97
98 #[error("HTTP error fetching version policy from {url}: status {status}")]
99 HttpError { url: String, status: u16 },
100
101 #[cfg(feature = "http")]
102 #[error("reqwest error: {0}")]
103 Reqwest(#[from] reqwest::Error),
104}
105
106impl VersionSignalingError {
107 fn from_body(body: CodecVersionRequiredBody) -> Self {
108 let features_suffix = if body.required_features.is_empty() {
109 String::new()
110 } else {
111 format!(" (requires: {})", body.required_features.join(", "))
112 };
113 let docs_suffix = match &body.docs_url {
114 Some(u) => format!(" See {u}"),
115 None => String::new(),
116 };
117 Self::VersionRequired {
118 minimum_version: body.minimum_version,
119 client_version: body.client_version,
120 required_features: body.required_features,
121 features_suffix,
122 docs_url: body.docs_url,
123 docs_suffix,
124 deployment_id: body.deployment_id,
125 }
126 }
127}
128
129pub fn well_known_version_policy_url(origin: &str) -> String {
131 format!("{}/.well-known/codec/version-policy.json", origin.trim_end_matches('/'))
132}
133
134pub fn parse_version_required(
138 status: &impl HttpStatus,
139 body_text: &str,
140) -> Result<Option<VersionSignalingError>, VersionSignalingError> {
141 if status.as_u16() != 426 {
142 return Ok(None);
143 }
144
145 let raw: serde_json::Value = match serde_json::from_str(body_text) {
146 Ok(v) => v,
147 Err(_) => {
148 return Err(VersionSignalingError::NonJsonBody(truncate(body_text, 200)));
149 }
150 };
151
152 let body: CodecVersionRequiredBody = match serde_json::from_value(raw.clone()) {
153 Ok(b) => b,
154 Err(_) => {
155 return Err(VersionSignalingError::UnrecognizedBody(truncate(body_text, 200)));
156 }
157 };
158
159 if body.error != "codec_version_required"
160 || body.minimum_version.is_empty()
161 || body.client_version.is_empty()
162 {
163 return Err(VersionSignalingError::UnrecognizedBody(truncate(body_text, 200)));
164 }
165
166 Ok(Some(VersionSignalingError::from_body(body)))
167}
168
169pub fn parse_version_policy_document(
171 raw: &str,
172) -> Result<CodecVersionPolicyDocument, VersionSignalingError> {
173 let doc: CodecVersionPolicyDocument = serde_json::from_str(raw)
174 .map_err(|e| VersionSignalingError::MalformedPolicyDoc(format!("{e}")))?;
175 if doc.minimum_version.is_empty() {
176 return Err(VersionSignalingError::MalformedPolicyDoc(
177 "missing minimum_version".into(),
178 ));
179 }
180 Ok(doc)
181}
182
183pub trait HttpStatus {
187 fn as_u16(&self) -> u16;
188}
189
190impl HttpStatus for u16 {
191 fn as_u16(&self) -> u16 {
192 *self
193 }
194}
195
196#[cfg(feature = "http")]
197impl HttpStatus for reqwest::StatusCode {
198 fn as_u16(&self) -> u16 {
199 (*self).as_u16()
200 }
201}
202
203fn truncate(s: &str, n: usize) -> String {
204 s.chars().take(n).collect()
205}
206
207#[cfg(feature = "http")]
210pub fn discover_version_policy_blocking(
214 origin: &str,
215 client: &reqwest::blocking::Client,
216) -> Result<Option<CodecVersionPolicyDocument>, VersionSignalingError> {
217 let url = well_known_version_policy_url(origin);
218 let resp = client
219 .get(&url)
220 .header(CODEC_CLIENT_VERSION_HEADER, CODEC_CLIENT_VERSION)
221 .send()?;
222
223 let status = resp.status().as_u16();
224 if status == 404 {
225 return Ok(None);
226 }
227 if status >= 400 {
228 return Err(VersionSignalingError::HttpError { url, status });
229 }
230 let text = resp.text()?;
231 parse_version_policy_document(&text).map(Some)
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 const VALID_BODY: &str = r#"{
239 "error": "codec_version_required",
240 "minimum_version": "0.4",
241 "required_features": ["safety-policy-enforcement"],
242 "client_version": "0.3",
243 "docs_url": "https://codecai.net/docs/version-negotiation/",
244 "deployment_id": "lab-test"
245 }"#;
246
247 #[test]
248 fn parse_returns_none_for_non_426() {
249 let result = parse_version_required(&200u16, r#"{"ok":true}"#).unwrap();
250 assert!(result.is_none());
251 }
252
253 #[test]
254 fn parse_returns_typed_error_for_valid_426() {
255 let result = parse_version_required(&426u16, VALID_BODY).unwrap();
256 assert!(result.is_some());
257 if let Some(VersionSignalingError::VersionRequired {
258 minimum_version,
259 client_version,
260 required_features,
261 docs_url,
262 deployment_id,
263 ..
264 }) = result
265 {
266 assert_eq!(minimum_version, "0.4");
267 assert_eq!(client_version, "0.3");
268 assert_eq!(required_features, vec!["safety-policy-enforcement"]);
269 assert_eq!(
270 docs_url,
271 Some("https://codecai.net/docs/version-negotiation/".to_string())
272 );
273 assert_eq!(deployment_id, Some("lab-test".to_string()));
274 } else {
275 panic!("expected VersionRequired variant");
276 }
277 }
278
279 #[test]
280 fn error_message_contains_version_info() {
281 let err = parse_version_required(&426u16, VALID_BODY).unwrap().unwrap();
282 let msg = format!("{err}");
283 assert!(msg.contains("requires v0.4"), "msg = {msg}");
284 assert!(msg.contains("safety-policy-enforcement"), "msg = {msg}");
285 assert!(msg.contains("speaks v0.3"), "msg = {msg}");
286 }
287
288 #[test]
289 fn parse_errors_on_non_json_body() {
290 let err = parse_version_required(&426u16, "plain text refusal").unwrap_err();
291 assert!(matches!(err, VersionSignalingError::NonJsonBody(_)));
292 }
293
294 #[test]
295 fn parse_errors_on_unrecognized_shape() {
296 let err =
297 parse_version_required(&426u16, r#"{"error":"something_else","foo":1}"#)
298 .unwrap_err();
299 assert!(matches!(err, VersionSignalingError::UnrecognizedBody(_)));
300 }
301
302 #[test]
303 fn parse_handles_empty_required_features() {
304 let body = r#"{
305 "error": "codec_version_required",
306 "minimum_version": "0.4",
307 "required_features": [],
308 "client_version": "0.3"
309 }"#;
310 let result = parse_version_required(&426u16, body).unwrap().unwrap();
311 let msg = format!("{result}");
312 assert!(!msg.contains("requires:"), "msg = {msg}");
313 }
314
315 #[test]
316 fn parse_policy_doc_valid() {
317 let body = r#"{
318 "minimum_version": "0.4",
319 "required_features": ["safety-policy-enforcement"],
320 "deployment_id": "acme-prod"
321 }"#;
322 let doc = parse_version_policy_document(body).unwrap();
323 assert_eq!(doc.minimum_version, "0.4");
324 assert_eq!(doc.required_features, vec!["safety-policy-enforcement"]);
325 }
326
327 #[test]
328 fn parse_policy_doc_rejects_missing_min_version() {
329 let err = parse_version_policy_document(r#"{"required_features":[]}"#).unwrap_err();
330 assert!(matches!(err, VersionSignalingError::MalformedPolicyDoc(_)));
331 }
332
333 #[test]
334 fn well_known_url_helper() {
335 assert_eq!(
336 well_known_version_policy_url("https://x.test/"),
337 "https://x.test/.well-known/codec/version-policy.json"
338 );
339 }
340
341 fn server_required_features(name: &str) -> Vec<String> {
344 match name {
345 "safety-enforced" => vec!["safety-policy-enforcement".into()],
346 "version-policy-strict" | "default-off" | _ => vec![],
347 }
348 }
349
350 fn server_refuses(name: &str, client: &str) -> bool {
351 let v04_min = matches!(client, "0.4" | "0.5");
352 match name {
353 "default-off" => false,
354 "safety-enforced" | "version-policy-strict" => !v04_min,
355 _ => false,
356 }
357 }
358
359 #[test]
360 fn matrix_full() {
361 let servers = ["default-off", "safety-enforced", "version-policy-strict"];
362 let clients = ["0.2", "0.3", "0.4", "0.5"];
363
364 for server in &servers {
365 for client in &clients {
366 let refused = server_refuses(server, client);
367 if refused {
368 let features = server_required_features(server);
369 let features_json = features
370 .iter()
371 .map(|f| format!("\"{f}\""))
372 .collect::<Vec<_>>()
373 .join(",");
374 let body = format!(
375 r#"{{
376 "error": "codec_version_required",
377 "minimum_version": "0.4",
378 "required_features": [{features_json}],
379 "client_version": "{client}"
380 }}"#
381 );
382 let result = parse_version_required(&426u16, &body)
383 .unwrap_or_else(|e| panic!("server={server} client={client}: {e}"));
384 assert!(
385 result.is_some(),
386 "server={server} client={client} expected refusal"
387 );
388 if let Some(VersionSignalingError::VersionRequired {
389 client_version, required_features, ..
390 }) = result {
391 assert_eq!(client_version, *client);
392 assert_eq!(required_features, features);
393 }
394 } else {
395 let result = parse_version_required(&200u16, r#"{"ok":true}"#).unwrap();
396 assert!(
397 result.is_none(),
398 "server={server} client={client} expected pass-through"
399 );
400 }
401 }
402 }
403 }
404}