1use 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
20const MAX_RETRY_AFTER_SECS: u64 = 3_600;
23
24pub struct SidecarModelAdapter {
29 client: reqwest::Client,
30 base_url: String,
31}
32
33impl SidecarModelAdapter {
34 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 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 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 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 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 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 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 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 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
252pub 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 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#[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[tokio::test]
702 async fn chat_errors_when_output_tokens_missing() {
703 let mut server = mockito::Server::new_async().await;
704
705 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 #[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}