Skip to main content

jamjet_models/
sidecar.rs

1//! Sidecar model adapter — POSTs to the Python model-seam sidecar.
2//!
3//! Set `JAMJET_MODEL_SEAM_URL` (e.g. `http://127.0.0.1:4280`) to route
4//! durable-path model calls through the governed Python seam (provider
5//! allow-list, PII redaction, cost metering, middleware).
6//!
7//! Sidecar contract:
8//! - `POST /v1/complete` — `{model, messages, temperature?, max_tokens?}`
9//!   → `{message:{content,role}, input_tokens, output_tokens, cost_usd, model, finish_reason}`
10//! - `GET /health` → `{ok:true}`
11
12use crate::adapter::{
13    ChatRole, ModelAdapter, ModelError, ModelRequest, ModelResponse, StructuredRequest, ToolCall,
14};
15use async_trait::async_trait;
16use serde_json::{json, Value};
17
18const DEFAULT_MODEL: &str = "anthropic/claude-sonnet-4-6";
19
20/// Cap a provider-supplied Retry-After so an untrusted/huge value can't overflow
21/// the timestamp math or push the backoff into the past. One hour is plenty.
22const MAX_RETRY_AFTER_SECS: u64 = 3_600;
23
24/// Routes durable-path model calls to the Python model-seam sidecar via HTTP.
25///
26/// The sidecar wraps `jamjet.model.Model` (Track-1 seam), so every call
27/// inherits the same governed middleware as in-process `Agent.run()`.
28pub struct SidecarModelAdapter {
29    client: reqwest::Client,
30    base_url: String,
31}
32
33impl SidecarModelAdapter {
34    /// Create an adapter that POSTs to `base_url` (e.g. `http://127.0.0.1:4280`).
35    pub fn new(base_url: impl Into<String>) -> Self {
36        Self {
37            client: reqwest::Client::new(),
38            base_url: base_url.into(),
39        }
40    }
41
42    async fn call_complete(&self, body: Value) -> Result<Value, ModelError> {
43        let url = format!("{}/v1/complete", self.base_url);
44        let resp = self
45            .client
46            .post(&url)
47            .json(&body)
48            .send()
49            .await
50            .map_err(|e| ModelError::Network(e.to_string()))?;
51
52        let status = resp.status().as_u16();
53        let text = resp
54            .text()
55            .await
56            .map_err(|e| ModelError::Network(e.to_string()))?;
57
58        if status == 429 {
59            // Prefer the retry_after the sidecar extracts from the provider response
60            // (passed back as `{"retry_after": <secs>}` in the body).  Fall back to
61            // 60 s when the body is absent or unparseable — keeps the existing safe
62            // default for responses that predate this contract.
63            let retry_after_secs = serde_json::from_str::<Value>(&text)
64                .ok()
65                .and_then(|v| v["retry_after"].as_u64())
66                .unwrap_or(60)
67                .min(MAX_RETRY_AFTER_SECS);
68            return Err(ModelError::RateLimited { retry_after_secs });
69        }
70        if status != 200 {
71            return Err(ModelError::Api { status, body: text });
72        }
73        serde_json::from_str(&text).map_err(|e| ModelError::Serialization(e.to_string()))
74    }
75
76    fn parse_response(&self, json: Value) -> Result<ModelResponse, ModelError> {
77        let content = json["message"]["content"]
78            .as_str()
79            .unwrap_or("")
80            .to_string();
81        let model = json["model"].as_str().unwrap_or(DEFAULT_MODEL).to_string();
82        let finish_reason = json["finish_reason"].as_str().unwrap_or("stop").to_string();
83
84        // I4: parse token counts strictly — a missing or wrong-type field means the
85        // metering data is corrupt; fail closed rather than silently zeroing the count.
86        // Cost is recorded by the Python MeteringMiddleware after C1; cost_usd is not
87        // threaded to the Rust event here.
88        // F-2e-cost: cost_usd from the Python MeteringMiddleware is not yet threaded to the Rust event.
89        let input_tokens = json["input_tokens"].as_u64().ok_or_else(|| {
90            ModelError::Serialization(
91                "sidecar response missing or invalid 'input_tokens' field".to_string(),
92            )
93        })?;
94        let output_tokens = json["output_tokens"].as_u64().ok_or_else(|| {
95            ModelError::Serialization(
96                "sidecar response missing or invalid 'output_tokens' field".to_string(),
97            )
98        })?;
99
100        // Parse tool_calls when the sidecar surfaces them (finish_reason == "tool_calls").
101        // The sidecar normalises arguments to a JSON object when possible; we store
102        // whatever Value arrives (object or string) so no information is lost.
103        let tool_calls: Vec<ToolCall> = json["tool_calls"]
104            .as_array()
105            .cloned()
106            .unwrap_or_default()
107            .into_iter()
108            .map(|tc| ToolCall {
109                id: tc["id"].as_str().unwrap_or("").to_string(),
110                name: tc["name"].as_str().unwrap_or("").to_string(),
111                arguments: tc["arguments"].clone(),
112            })
113            .collect();
114
115        // 2j fail-closed: when the model is requesting tool calls, this data drives
116        // tool dispatch and tool_call_id correlation downstream. A missing/empty
117        // array, or a call with a blank id or name, would silently degrade to
118        // "no tool executed" or mismatched correlation — a provider/sidecar bug
119        // must surface as an error here rather than as a wrong answer.
120        if finish_reason == "tool_calls" {
121            if tool_calls.is_empty() {
122                return Err(ModelError::Serialization(
123                    "sidecar finish_reason is 'tool_calls' but tool_calls is missing or empty"
124                        .to_string(),
125                ));
126            }
127            if let Some(bad) = tool_calls
128                .iter()
129                .find(|tc| tc.id.is_empty() || tc.name.is_empty())
130            {
131                return Err(ModelError::Serialization(format!(
132                    "sidecar tool_call missing id or name (id={:?}, name={:?})",
133                    bad.id, bad.name
134                )));
135            }
136        }
137
138        Ok(ModelResponse {
139            content,
140            model,
141            finish_reason,
142            input_tokens,
143            output_tokens,
144            structured: None,
145            tool_calls,
146        })
147    }
148
149    fn build_messages(
150        messages: &[crate::adapter::ChatMessage],
151        system_prompt: Option<&str>,
152    ) -> Vec<Value> {
153        let mut out: Vec<Value> = Vec::new();
154        // Inject system prompt as leading system message if configured.
155        if let Some(sys) = system_prompt {
156            if !sys.is_empty() {
157                out.push(json!({ "role": "system", "content": sys }));
158            }
159        }
160        for m in messages {
161            let role = match m.role {
162                ChatRole::System => "system",
163                ChatRole::User | ChatRole::Tool => "user",
164                ChatRole::Assistant => "assistant",
165            };
166            out.push(json!({ "role": role, "content": m.content }));
167        }
168        out
169    }
170}
171
172#[async_trait]
173impl ModelAdapter for SidecarModelAdapter {
174    fn system_name(&self) -> &'static str {
175        "sidecar"
176    }
177
178    fn default_model(&self) -> &str {
179        DEFAULT_MODEL
180    }
181
182    async fn chat(&self, request: ModelRequest) -> Result<ModelResponse, ModelError> {
183        let model = request
184            .config
185            .model
186            .clone()
187            .unwrap_or_else(|| DEFAULT_MODEL.into());
188
189        let messages =
190            Self::build_messages(&request.messages, request.config.system_prompt.as_deref());
191
192        let mut body = json!({
193            "model": model,
194            "messages": messages,
195        });
196        if let Some(temp) = request.config.temperature {
197            body["temperature"] = json!(temp);
198        }
199        if let Some(max) = request.config.max_tokens {
200            body["max_tokens"] = json!(max);
201        }
202        // Forward tool schemas to the sidecar when tools are offered; the governed
203        // seam (allowlist + PII + metering middleware) still runs on the sidecar side.
204        if !request.tools.is_empty() {
205            body["tools"] = json!(request.tools);
206        }
207
208        let resp_json = self.call_complete(body).await?;
209        self.parse_response(resp_json)
210    }
211
212    async fn structured_output(
213        &self,
214        request: StructuredRequest,
215    ) -> Result<ModelResponse, ModelError> {
216        // Append schema instruction to system prompt (mirrors AnthropicAdapter).
217        let schema_str = serde_json::to_string_pretty(&request.output_schema)
218            .map_err(|e| ModelError::Serialization(e.to_string()))?;
219        let mut config = request.config.clone();
220        let system = config.system_prompt.get_or_insert_with(String::new);
221        system.push_str(&format!(
222            "\n\nRespond ONLY with a valid JSON object matching this schema:\n{schema_str}\nDo not include any other text."
223        ));
224
225        let chat_req = ModelRequest {
226            messages: request.messages,
227            config,
228            tools: vec![],
229        };
230        let mut response = self.chat(chat_req).await?;
231
232        // Parse structured output from the response content.
233        let structured = serde_json::from_str::<Value>(&response.content)
234            .or_else(|_| {
235                let trimmed = response.content.trim();
236                let inner = trimmed
237                    .trim_start_matches("```json")
238                    .trim_start_matches("```")
239                    .trim_end_matches("```")
240                    .trim();
241                serde_json::from_str::<Value>(inner)
242            })
243            .map_err(|e| {
244                ModelError::Serialization(format!("failed to parse structured output: {e}"))
245            })?;
246
247        response.structured = Some(structured);
248        Ok(response)
249    }
250}
251
252// ── Coverage guard ────────────────────────────────────────────────────────────
253
254/// Probe the sidecar `/health` endpoint at startup.
255///
256/// Returns `Err` with a descriptive message if the sidecar is unreachable or
257/// responds with a non-2xx status — so a misconfigured deployment fails loud
258/// rather than silently falling through to the native (ungoverned) adapters.
259pub async fn check_sidecar_health(
260    base_url: &str,
261    client: &reqwest::Client,
262) -> Result<(), ModelError> {
263    let url = format!("{base_url}/health");
264    let resp = client.get(&url).send().await.map_err(|e| {
265        ModelError::Network(format!(
266            "JAMJET_MODEL_SEAM_URL set but sidecar unreachable at {url} — \
267             refusing to start so model calls never silently bypass the governed seam. \
268             Cause: {e}"
269        ))
270    })?;
271
272    let status = resp.status();
273    if !status.is_success() {
274        let code = status.as_u16();
275        let body = resp.text().await.unwrap_or_default();
276        return Err(ModelError::Api {
277            status: code,
278            body: format!(
279                "JAMJET_MODEL_SEAM_URL set but sidecar /health returned {code} — \
280                 refusing to start so model calls never silently bypass the governed seam. \
281                 Body: {body}"
282            ),
283        });
284    }
285
286    // I3: also validate the JSON body — a wrong service returning 200 must not pass.
287    // The sidecar contract guarantees {"ok": true}; anything else is treated as a failure.
288    let body = resp
289        .text()
290        .await
291        .map_err(|e| ModelError::Network(e.to_string()))?;
292    let json: serde_json::Value = serde_json::from_str(&body).map_err(|_| {
293        ModelError::Serialization(format!(
294            "sidecar /health returned a non-JSON body — \
295             refusing to start. Body: {body}"
296        ))
297    })?;
298    if json.get("ok").and_then(|v| v.as_bool()) != Some(true) {
299        return Err(ModelError::Api {
300            status: status.as_u16(),
301            body: format!(
302                "sidecar /health did not return {{\"ok\":true}} — \
303                 refusing to start. Body: {body}"
304            ),
305        });
306    }
307
308    Ok(())
309}
310
311// ── Tests ─────────────────────────────────────────────────────────────────────
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use crate::adapter::ChatMessage;
317
318    #[tokio::test]
319    async fn chat_maps_response_fields() {
320        let mut server = mockito::Server::new_async().await;
321
322        let _mock = server
323            .mock("POST", "/v1/complete")
324            .with_status(200)
325            .with_header("content-type", "application/json")
326            .with_body(
327                r#"{
328                "message": {"content": "Hello, world!", "role": "assistant"},
329                "input_tokens": 10,
330                "output_tokens": 5,
331                "cost_usd": 0.001,
332                "model": "anthropic/claude-sonnet-4-6",
333                "finish_reason": "stop"
334            }"#,
335            )
336            .create_async()
337            .await;
338
339        let adapter = SidecarModelAdapter::new(server.url());
340        let req = ModelRequest::new(vec![ChatMessage::user("hi")]);
341        let resp = adapter.chat(req).await.expect("chat should succeed");
342
343        assert_eq!(resp.content, "Hello, world!");
344        assert_eq!(resp.input_tokens, 10);
345        assert_eq!(resp.output_tokens, 5);
346        assert_eq!(resp.model, "anthropic/claude-sonnet-4-6");
347        assert_eq!(resp.finish_reason, "stop");
348        assert!(resp.structured.is_none());
349    }
350
351    #[tokio::test]
352    async fn chat_sends_temperature_and_max_tokens() {
353        use crate::adapter::ModelConfig;
354
355        let mut server = mockito::Server::new_async().await;
356        let _mock = server
357            .mock("POST", "/v1/complete")
358            .match_body(mockito::Matcher::PartialJsonString(
359                r#"{"temperature":0.5,"max_tokens":256}"#.into(),
360            ))
361            .with_status(200)
362            .with_header("content-type", "application/json")
363            .with_body(
364                r#"{
365                "message":{"content":"ok","role":"assistant"},
366                "input_tokens":1,"output_tokens":1,
367                "model":"anthropic/claude-sonnet-4-6","finish_reason":"stop"
368            }"#,
369            )
370            .create_async()
371            .await;
372
373        let adapter = SidecarModelAdapter::new(server.url());
374        let req = ModelRequest::new(vec![ChatMessage::user("hi")]).with_config(ModelConfig {
375            temperature: Some(0.5),
376            max_tokens: Some(256),
377            ..Default::default()
378        });
379        adapter.chat(req).await.expect("should succeed");
380    }
381
382    #[tokio::test]
383    async fn chat_errors_on_non_200() {
384        let mut server = mockito::Server::new_async().await;
385
386        let _mock = server
387            .mock("POST", "/v1/complete")
388            .with_status(500)
389            .with_body("internal server error")
390            .create_async()
391            .await;
392
393        let adapter = SidecarModelAdapter::new(server.url());
394        let req = ModelRequest::new(vec![ChatMessage::user("hi")]);
395        let result = adapter.chat(req).await;
396
397        assert!(
398            matches!(result, Err(ModelError::Api { status: 500, .. })),
399            "expected Api error with status 500, got {result:?}"
400        );
401    }
402
403    #[tokio::test]
404    async fn chat_errors_on_rate_limit() {
405        let mut server = mockito::Server::new_async().await;
406
407        let _mock = server
408            .mock("POST", "/v1/complete")
409            .with_status(429)
410            .with_body("rate limited")
411            .create_async()
412            .await;
413
414        let adapter = SidecarModelAdapter::new(server.url());
415        let req = ModelRequest::new(vec![ChatMessage::user("hi")]);
416        let result = adapter.chat(req).await;
417
418        assert!(
419            matches!(result, Err(ModelError::RateLimited { .. })),
420            "expected RateLimited, got {result:?}"
421        );
422    }
423
424    // 2f-5: 429 body with retry_after must be propagated into ModelError::RateLimited.
425    #[tokio::test]
426    async fn chat_rate_limit_uses_body_retry_after() {
427        let mut server = mockito::Server::new_async().await;
428
429        let _mock = server
430            .mock("POST", "/v1/complete")
431            .with_status(429)
432            .with_header("content-type", "application/json")
433            .with_body(r#"{"error":"rate limit","retry_after":12}"#)
434            .create_async()
435            .await;
436
437        let adapter = SidecarModelAdapter::new(server.url());
438        let req = ModelRequest::new(vec![ChatMessage::user("hi")]);
439        let result = adapter.chat(req).await;
440
441        assert!(
442            matches!(
443                result,
444                Err(ModelError::RateLimited {
445                    retry_after_secs: 12
446                })
447            ),
448            "expected RateLimited{{retry_after_secs:12}}, got {result:?}"
449        );
450    }
451
452    // 2f-5: 429 with no parseable body falls back to 60 s default.
453    #[tokio::test]
454    async fn chat_rate_limit_falls_back_when_no_retry_after() {
455        let mut server = mockito::Server::new_async().await;
456
457        let _mock = server
458            .mock("POST", "/v1/complete")
459            .with_status(429)
460            .with_body("too many requests")
461            .create_async()
462            .await;
463
464        let adapter = SidecarModelAdapter::new(server.url());
465        let req = ModelRequest::new(vec![ChatMessage::user("hi")]);
466        let result = adapter.chat(req).await;
467
468        assert!(
469            matches!(
470                result,
471                Err(ModelError::RateLimited {
472                    retry_after_secs: 60
473                })
474            ),
475            "expected RateLimited{{retry_after_secs:60}}, got {result:?}"
476        );
477    }
478
479    #[tokio::test]
480    async fn health_check_passes_on_200() {
481        let mut server = mockito::Server::new_async().await;
482
483        let _mock = server
484            .mock("GET", "/health")
485            .with_status(200)
486            .with_header("content-type", "application/json")
487            .with_body(r#"{"ok":true}"#)
488            .create_async()
489            .await;
490
491        let client = reqwest::Client::new();
492        check_sidecar_health(&server.url(), &client)
493            .await
494            .expect("health check should pass");
495    }
496
497    #[tokio::test]
498    async fn health_check_errors_on_non_200() {
499        let mut server = mockito::Server::new_async().await;
500
501        let _mock = server
502            .mock("GET", "/health")
503            .with_status(503)
504            .with_body("unavailable")
505            .create_async()
506            .await;
507
508        let client = reqwest::Client::new();
509        let result = check_sidecar_health(&server.url(), &client).await;
510        assert!(
511            matches!(result, Err(ModelError::Api { status: 503, .. })),
512            "expected Api error with status 503, got {result:?}"
513        );
514    }
515
516    #[tokio::test]
517    async fn health_check_errors_on_unreachable() {
518        // Port 1 is never listening.
519        let client = reqwest::Client::new();
520        let result = check_sidecar_health("http://127.0.0.1:1", &client).await;
521        assert!(
522            matches!(result, Err(ModelError::Network(_))),
523            "expected Network error, got {result:?}"
524        );
525    }
526
527    // I3: health guard must reject ok=false and non-JSON 200 bodies.
528
529    #[tokio::test]
530    async fn health_check_errors_on_ok_false() {
531        let mut server = mockito::Server::new_async().await;
532
533        let _mock = server
534            .mock("GET", "/health")
535            .with_status(200)
536            .with_header("content-type", "application/json")
537            .with_body(r#"{"ok":false}"#)
538            .create_async()
539            .await;
540
541        let client = reqwest::Client::new();
542        let result = check_sidecar_health(&server.url(), &client).await;
543        assert!(
544            matches!(result, Err(ModelError::Api { .. })),
545            "health guard must reject {{\"ok\":false}}, got {result:?}"
546        );
547    }
548
549    #[tokio::test]
550    async fn health_check_errors_on_non_json_200() {
551        let mut server = mockito::Server::new_async().await;
552
553        let _mock = server
554            .mock("GET", "/health")
555            .with_status(200)
556            .with_header("content-type", "text/plain")
557            .with_body("OK")
558            .create_async()
559            .await;
560
561        let client = reqwest::Client::new();
562        let result = check_sidecar_health(&server.url(), &client).await;
563        assert!(
564            matches!(result, Err(ModelError::Serialization(_))),
565            "health guard must reject a non-JSON 200 body, got {result:?}"
566        );
567    }
568
569    // 2j-1: tool_calls round-trip — sidecar returns tool_calls -> ModelResponse.tool_calls populated.
570    #[tokio::test]
571    async fn chat_returns_tool_calls_from_sidecar() {
572        let mut server = mockito::Server::new_async().await;
573
574        let _mock = server
575            .mock("POST", "/v1/complete")
576            .with_status(200)
577            .with_header("content-type", "application/json")
578            .with_body(
579                r#"{
580                "message": {"content": null, "role": "assistant"},
581                "tool_calls": [{"id": "c1", "name": "get_weather", "arguments": {"city": "SF"}}],
582                "finish_reason": "tool_calls",
583                "input_tokens": 5,
584                "output_tokens": 3,
585                "model": "anthropic/claude-sonnet-4-6"
586            }"#,
587            )
588            .create_async()
589            .await;
590
591        let adapter = SidecarModelAdapter::new(server.url());
592        let req = ModelRequest::new(vec![ChatMessage::user("what's the weather?")]);
593        let resp = adapter.chat(req).await.expect("chat should succeed");
594
595        assert_eq!(resp.finish_reason, "tool_calls");
596        assert_eq!(resp.tool_calls.len(), 1);
597        let tc = &resp.tool_calls[0];
598        assert_eq!(tc.id, "c1");
599        assert_eq!(tc.name, "get_weather");
600        assert_eq!(tc.arguments, serde_json::json!({"city": "SF"}));
601    }
602
603    // 2j fail-closed: finish_reason "tool_calls" but no tool_calls array must Err,
604    // not silently degrade to an empty dispatch.
605    #[tokio::test]
606    async fn chat_errors_when_tool_calls_missing_for_tool_finish() {
607        let mut server = mockito::Server::new_async().await;
608
609        let _mock = server
610            .mock("POST", "/v1/complete")
611            .with_status(200)
612            .with_header("content-type", "application/json")
613            .with_body(
614                r#"{
615                "message": {"content": null, "role": "assistant"},
616                "finish_reason": "tool_calls",
617                "input_tokens": 5,
618                "output_tokens": 3,
619                "model": "anthropic/claude-sonnet-4-6"
620            }"#,
621            )
622            .create_async()
623            .await;
624
625        let adapter = SidecarModelAdapter::new(server.url());
626        let req = ModelRequest::new(vec![ChatMessage::user("hi")]);
627        let result = adapter.chat(req).await;
628
629        assert!(
630            matches!(result, Err(ModelError::Serialization(_))),
631            "finish_reason tool_calls with no tool_calls must Err, got {result:?}"
632        );
633    }
634
635    // 2j fail-closed: a tool_call missing its id (or name) must Err — a blank id
636    // would break tool_call_id correlation downstream.
637    #[tokio::test]
638    async fn chat_errors_when_tool_call_missing_id() {
639        let mut server = mockito::Server::new_async().await;
640
641        let _mock = server
642            .mock("POST", "/v1/complete")
643            .with_status(200)
644            .with_header("content-type", "application/json")
645            .with_body(
646                r#"{
647                "message": {"content": null, "role": "assistant"},
648                "tool_calls": [{"name": "get_weather", "arguments": {"city": "SF"}}],
649                "finish_reason": "tool_calls",
650                "input_tokens": 5,
651                "output_tokens": 3,
652                "model": "anthropic/claude-sonnet-4-6"
653            }"#,
654            )
655            .create_async()
656            .await;
657
658        let adapter = SidecarModelAdapter::new(server.url());
659        let req = ModelRequest::new(vec![ChatMessage::user("what's the weather?")]);
660        let result = adapter.chat(req).await;
661
662        assert!(
663            matches!(result, Err(ModelError::Serialization(_))),
664            "tool_call with a blank id must Err, got {result:?}"
665        );
666    }
667
668    // 2j-1: tools forwarded — a request with non-empty tools sends them in the POST body.
669    #[tokio::test]
670    async fn chat_sends_tools_in_post_body() {
671        let mut server = mockito::Server::new_async().await;
672
673        let _mock = server
674            .mock("POST", "/v1/complete")
675            .match_body(mockito::Matcher::PartialJsonString(
676                r#"{"tools":[{"type":"function","function":{"name":"get_weather"}}]}"#.into(),
677            ))
678            .with_status(200)
679            .with_header("content-type", "application/json")
680            .with_body(
681                r#"{
682                "message": {"content": "ok", "role": "assistant"},
683                "finish_reason": "stop",
684                "input_tokens": 5,
685                "output_tokens": 3,
686                "model": "anthropic/claude-sonnet-4-6"
687            }"#,
688            )
689            .create_async()
690            .await;
691
692        let adapter = SidecarModelAdapter::new(server.url());
693        let req = ModelRequest::new(vec![ChatMessage::user("hi")]).with_tools(vec![
694            serde_json::json!({"type": "function", "function": {"name": "get_weather"}}),
695        ]);
696        adapter.chat(req).await.expect("chat should succeed");
697    }
698
699    // I4: missing output_tokens must cause chat() to return Err, not a zero-metered response.
700
701    #[tokio::test]
702    async fn chat_errors_when_output_tokens_missing() {
703        let mut server = mockito::Server::new_async().await;
704
705        // Response omits output_tokens — the old unwrap_or(0) would silently zero it.
706        let _mock = server
707            .mock("POST", "/v1/complete")
708            .with_status(200)
709            .with_header("content-type", "application/json")
710            .with_body(
711                r#"{
712                "message": {"content": "hi", "role": "assistant"},
713                "input_tokens": 5,
714                "model": "anthropic/claude-sonnet-4-6",
715                "finish_reason": "stop"
716            }"#,
717            )
718            .create_async()
719            .await;
720
721        let adapter = SidecarModelAdapter::new(server.url());
722        let req = ModelRequest::new(vec![ChatMessage::user("hi")]);
723        let result = adapter.chat(req).await;
724
725        assert!(
726            matches!(result, Err(ModelError::Serialization(_))),
727            "missing output_tokens must produce Serialization error, got {result:?}"
728        );
729    }
730
731    // 2f-security: a huge (malicious or buggy) provider Retry-After must be clamped
732    // to MAX_RETRY_AFTER_SECS so it cannot overflow the timestamp math in the worker.
733    #[tokio::test]
734    async fn chat_rate_limit_clamps_huge_retry_after() {
735        let mut server = mockito::Server::new_async().await;
736
737        let _mock = server
738            .mock("POST", "/v1/complete")
739            .with_status(429)
740            .with_header("content-type", "application/json")
741            .with_body(r#"{"error":"rate limit","retry_after":99999999999999}"#)
742            .create_async()
743            .await;
744
745        let adapter = SidecarModelAdapter::new(server.url());
746        let req = ModelRequest::new(vec![ChatMessage::user("hi")]);
747        let result = adapter.chat(req).await;
748
749        match result {
750            Err(ModelError::RateLimited { retry_after_secs }) => {
751                assert!(
752                    retry_after_secs <= MAX_RETRY_AFTER_SECS,
753                    "retry_after_secs {retry_after_secs} must be <= MAX_RETRY_AFTER_SECS ({MAX_RETRY_AFTER_SECS})"
754                );
755            }
756            other => panic!("expected RateLimited, got {other:?}"),
757        }
758    }
759
760    #[tokio::test]
761    async fn chat_errors_when_input_tokens_missing() {
762        let mut server = mockito::Server::new_async().await;
763
764        let _mock = server
765            .mock("POST", "/v1/complete")
766            .with_status(200)
767            .with_header("content-type", "application/json")
768            .with_body(
769                r#"{
770                "message": {"content": "hi", "role": "assistant"},
771                "output_tokens": 3,
772                "model": "anthropic/claude-sonnet-4-6",
773                "finish_reason": "stop"
774            }"#,
775            )
776            .create_async()
777            .await;
778
779        let adapter = SidecarModelAdapter::new(server.url());
780        let req = ModelRequest::new(vec![ChatMessage::user("hi")]);
781        let result = adapter.chat(req).await;
782
783        assert!(
784            matches!(result, Err(ModelError::Serialization(_))),
785            "missing input_tokens must produce Serialization error, got {result:?}"
786        );
787    }
788}