Skip to main content

codec_rs/
version_signaling.rs

1//! Codec v0.4 version negotiation — client-side primitives.
2//!
3//! Rust mirror of `packages/web/src/version-signaling.ts` and
4//! `packages/python/src/codecai/version_signaling.py`.
5//!
6//! See `spec/versions/v0.4.md`:
7//!
8//!   - § Version Compatibility Signaling (Codec-Client-Version, 426 path)
9//!   - § Capabilities are opt-on at the server (two-stage)
10//!   - § Graceful downgrade (response shaping)
11//!
12//! Usage:
13//!
14//! ```rust,no_run
15//! # #[cfg(feature = "http")]
16//! # fn _doc() -> Result<(), Box<dyn std::error::Error>> {
17//! use codec_rs::version_signaling::{
18//!     CODEC_CLIENT_VERSION, CODEC_CLIENT_VERSION_HEADER,
19//!     parse_version_required,
20//! };
21//!
22//! let client = reqwest::blocking::Client::new();
23//! let resp = client.post("https://server.test/v1/completions")
24//!     .header(CODEC_CLIENT_VERSION_HEADER, CODEC_CLIENT_VERSION)
25//!     .body("...")
26//!     .send()?;
27//!
28//! if let Some(err) = parse_version_required(&resp.status(), &resp.text()?)? {
29//!     return Err(err.into());
30//! }
31//! # Ok(()) }
32//! ```
33
34use serde::{Deserialize, Serialize};
35
36/// The protocol version this crate speaks. Bumped when the crate
37/// implements support for a new minor protocol version.
38pub const CODEC_CLIENT_VERSION: &str = "0.4";
39
40/// Request header name (canonical case).
41pub const CODEC_CLIENT_VERSION_HEADER: &str = "Codec-Client-Version";
42
43/// Response header name; advisory on 2xx, load-bearing on 426.
44pub const CODEC_MIN_VERSION_HEADER: &str = "Codec-Min-Version";
45
46/// Response header name; emitted on 426.
47pub const CODEC_REQUIRED_FEATURES_HEADER: &str = "Codec-Required-Features";
48
49/// Shape of the JSON body on a v0.4 server's 426 response.
50#[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/// Shape of `.well-known/codec/version-policy.json`.
63#[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/// Error raised when a v0.4-mandating server refuses with a 426.
76#[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
129/// Build the well-known URL for an origin.
130pub fn well_known_version_policy_url(origin: &str) -> String {
131    format!("{}/.well-known/codec/version-policy.json", origin.trim_end_matches('/'))
132}
133
134/// Parse a (status, body) pair into a typed error. Returns `Ok(None)` for
135/// non-426 responses, `Ok(Some(err))` for valid 426 bodies, and `Err` for
136/// 426s with malformed/non-JSON bodies — never silently swallows a 426.
137pub 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
169/// Parse + validate a version-policy document.
170pub 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
183/// Trait abstracting an HTTP status code so we can test without
184/// pulling in reqwest. `reqwest::StatusCode` and `u16` both implement
185/// it via the blanket impls below.
186pub 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// ── Pre-flight discovery ────────────────────────────────────────────────
208
209#[cfg(feature = "http")]
210/// Pre-flight fetch of the deployment's minimum-version policy.
211/// Returns `Ok(None)` when the server returns 404 (unrestricted
212/// deployment). Returns `Err` on 5xx or malformed body.
213pub 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    // ── Matrix: client × server config ──────────────────────────────────
342
343    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}