Skip to main content

ag_harness/provider/
kimi.rs

1use crate::chat_completion;
2
3pub(crate) const PROVIDER_NAME: &str = "moonshot_ai";
4pub(crate) const POLICY: chat_completion::ChatCompletionProviderPolicy =
5    chat_completion::ChatCompletionProviderPolicy {
6        display_name: "Kimi",
7        structured_output: chat_completion::StructuredOutputMode::JsonObject {
8            assistant_reasoning_content: true,
9            tool_result_name: true,
10        },
11        telemetry_name: PROVIDER_NAME,
12        unsupported_schema_reason: "Kimi JSON Object mode requires an explicit object root schema",
13    };
14
15/// Configuration for a Kimi model served through Moonshot AI's
16/// OpenAI-compatible API.
17pub struct KimiConfig {
18    /// API key sent as a bearer token.
19    pub api_key: String,
20    /// API base URL ending in the OpenAI-compatible version path.
21    pub base_url: String,
22    /// Kimi model identifier sent with each request.
23    pub model: String,
24}
25
26#[cfg(test)]
27mod tests {
28    use serde_json::{Value, json};
29    use wiremock::matchers::{bearer_token, body_json, method, path};
30    use wiremock::{Mock, MockServer, ResponseTemplate};
31
32    use super::*;
33    use crate::chat_completion::{
34        ERROR_BODY_LIMIT_BYTES, RESPONSE_ENVELOPE_LIMIT_BYTES, STRUCTURED_OUTPUT_INSTRUCTION,
35        SUCCESS_BODY_LIMIT_BYTES,
36    };
37    use crate::{model, schema_contract, tool};
38
39    fn person_schema_value() -> Value {
40        json!({
41            "type": "object",
42            "properties": {
43                "name": { "type": "string" }
44            },
45            "required": ["name"],
46            "additionalProperties": false
47        })
48    }
49
50    fn person_schema() -> crate::OutputSchema {
51        crate::OutputSchema::new(person_schema_value()).expect("schema should be valid")
52    }
53
54    fn request(prompt: &str) -> model::ModelRequest {
55        model::ModelRequest::new(prompt, person_schema())
56    }
57
58    fn read_request(prompt: &str) -> model::ModelRequest {
59        request(prompt).with_tool(tool::ToolDefinition::read())
60    }
61
62    fn read_tool_wire() -> Value {
63        let definition = tool::ToolDefinition::read();
64
65        json!({
66            "type": "function",
67            "function": {
68                "description": definition.description(),
69                "name": definition.name(),
70                "parameters": definition.parameters()
71            }
72        })
73    }
74
75    fn escaped_value_schema() -> crate::OutputSchema {
76        crate::OutputSchema::new(json!({
77            "type": "object",
78            "properties": {
79                "value": { "type": "string" }
80            },
81            "required": ["value"],
82            "additionalProperties": false
83        }))
84        .expect("schema should be valid")
85    }
86
87    fn kimi(server: &MockServer) -> model::ModelClient {
88        model::ModelClient::kimi(KimiConfig {
89            api_key: "test-key".to_string(),
90            base_url: format!("{}/", server.uri()),
91            model: "kimi-k2.6".to_string(),
92        })
93        .expect("fixture configuration should be valid")
94    }
95
96    #[test]
97    fn metadata_exposes_provider_and_model() {
98        // Arrange
99        let model = model::ModelClient::kimi(KimiConfig {
100            api_key: "test-key".to_string(),
101            base_url: "https://api.moonshot.example/v1".to_string(),
102            model: "kimi-k2.6".to_string(),
103        })
104        .expect("fixture configuration should be valid");
105
106        // Act
107        let metadata = model.metadata();
108
109        // Assert
110        assert_eq!(metadata.provider(), "moonshot_ai");
111        assert_eq!(metadata.model(), "kimi-k2.6");
112    }
113
114    #[test]
115    fn rejects_empty_model_during_construction() {
116        // Arrange
117        let config = KimiConfig {
118            api_key: "test-key".to_string(),
119            base_url: "https://api.moonshot.example/v1".to_string(),
120            model: "  ".to_string(),
121        };
122
123        // Act
124        let error = model::ModelClient::kimi(config)
125            .err()
126            .expect("empty model configuration should be rejected");
127
128        // Assert
129        assert_eq!(error, model::ModelMetadataError::EmptyModel);
130    }
131
132    async fn mount_structured_response(
133        server: &MockServer,
134        prompt: &str,
135        schema: &Value,
136        content: &str,
137    ) {
138        Mock::given(method("POST"))
139            .and(path("/chat/completions"))
140            .and(bearer_token("test-key"))
141            .and(body_json(json!({
142                "messages": [
143                    {
144                        "content": format!("{STRUCTURED_OUTPUT_INSTRUCTION}{schema}"),
145                        "role": "system"
146                    },
147                    {"content": prompt, "role": "user"}
148                ],
149                "model": "kimi-k2.6",
150                "response_format": {"type": "json_object"}
151            })))
152            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
153                "choices": [{
154                    "finish_reason": "stop",
155                    "message": {"content": content}
156                }]
157            })))
158            .expect(1)
159            .mount(server)
160            .await;
161    }
162
163    async fn mount_tool_response(server: &MockServer, prompt: &str, message: Value) {
164        Mock::given(method("POST"))
165            .and(path("/chat/completions"))
166            .and(bearer_token("test-key"))
167            .and(body_json(json!({
168                "messages": [
169                    {
170                        "content": format!(
171                            "{STRUCTURED_OUTPUT_INSTRUCTION}{}",
172                            person_schema_value()
173                        ),
174                        "role": "system"
175                    },
176                    {"content": prompt, "role": "user"}
177                ],
178                "model": "kimi-k2.6",
179                "response_format": {"type": "json_object"},
180                "tools": [read_tool_wire()]
181            })))
182            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
183                "choices": [{
184                    "finish_reason": "tool_calls",
185                    "message": message
186                }]
187            })))
188            .expect(1)
189            .mount(server)
190            .await;
191    }
192
193    #[tokio::test]
194    async fn accepts_object_root_in_type_array() {
195        // Arrange
196        let server = MockServer::start().await;
197        let schema_value = json!({ "type": ["object"] });
198        mount_structured_response(&server, "return an object", &schema_value, "{}").await;
199        let model = kimi(&server);
200        let schema = crate::OutputSchema::new(schema_value).expect("schema should be valid");
201
202        // Act
203        let response = model
204            .complete(model::ModelRequest::new("return an object", schema))
205            .await
206            .expect("Kimi request should succeed");
207
208        // Assert
209        assert_eq!(response.output(), Some(&json!({})));
210    }
211
212    #[tokio::test]
213    async fn completes_structured_request() {
214        // Arrange
215        let server = MockServer::start().await;
216        let schema_value = person_schema_value();
217        mount_structured_response(
218            &server,
219            "extract the name",
220            &schema_value,
221            r#"{"name":"Ada"}"#,
222        )
223        .await;
224        let model = kimi(&server);
225
226        // Act
227        let response = model
228            .complete(request("extract the name"))
229            .await
230            .expect("Kimi request should succeed");
231
232        // Assert
233        assert_eq!(response.output(), Some(&json!({ "name": "Ada" })));
234    }
235
236    #[tokio::test]
237    async fn advertises_and_decodes_read_tool_call() {
238        // Arrange
239        let server = MockServer::start().await;
240        mount_tool_response(
241            &server,
242            "inspect the manifest",
243            json!({
244                "content": null,
245                "tool_calls": [{
246                    "id": "call_kimi_read",
247                    "type": "function",
248                    "function": {
249                        "name": "read",
250                        "arguments": r#"{"path":"Cargo.toml","offset":1,"limit":12}"#
251                    }
252                }]
253            }),
254        )
255        .await;
256        let model = kimi(&server);
257
258        // Act
259        let response = model
260            .complete(read_request("inspect the manifest"))
261            .await
262            .expect("Kimi read request should decode");
263
264        // Assert
265        assert!(response.output().is_none());
266        let call = response
267            .call()
268            .expect("response should contain a tool call");
269        assert_eq!(call.id(), "call_kimi_read");
270        assert_eq!(call.name(), "read");
271        assert_eq!(call.arguments().path(), "Cargo.toml");
272        assert_eq!(call.arguments().offset(), Some(1));
273        assert_eq!(call.arguments().limit(), Some(12));
274    }
275
276    #[tokio::test]
277    async fn preserves_reasoning_and_named_tool_result_for_continuation() {
278        // Arrange
279        let server = MockServer::start().await;
280        let prompt = "inspect the manifest";
281        let result = r#"{"content":"[workspace]","end_line":1,"next_offset":null,"path":"Cargo.toml","start_line":1,"truncated":false}"#;
282        let reasoning_content = "I should inspect the manifest before answering.";
283        mount_tool_response(
284            &server,
285            prompt,
286            json!({
287                "content": null,
288                "reasoning_content": reasoning_content,
289                "tool_calls": [{
290                    "id": "call_kimi_read",
291                    "type": "function",
292                    "function": {
293                        "name": "read",
294                        "arguments": r#"{"path":"Cargo.toml","offset":1,"limit":12}"#
295                    }
296                }]
297            }),
298        )
299        .await;
300        Mock::given(method("POST"))
301            .and(path("/chat/completions"))
302            .and(bearer_token("test-key"))
303            .and(body_json(json!({
304                "messages": [
305                    {
306                        "content": format!(
307                            "{STRUCTURED_OUTPUT_INSTRUCTION}{}",
308                            person_schema_value()
309                        ),
310                        "role": "system"
311                    },
312                    {"content": prompt, "role": "user"},
313                    {
314                        "content": null,
315                        "reasoning_content": reasoning_content,
316                        "role": "assistant",
317                        "tool_calls": [{
318                            "function": {
319                                "arguments": r#"{"limit":12,"offset":1,"path":"Cargo.toml"}"#,
320                                "name": "read"
321                            },
322                            "id": "call_kimi_read",
323                            "type": "function"
324                        }]
325                    },
326                    {
327                        "content": result,
328                        "name": "read",
329                        "role": "tool",
330                        "tool_call_id": "call_kimi_read"
331                    }
332                ],
333                "model": "kimi-k2.6",
334                "response_format": {"type": "json_object"},
335                "tools": [read_tool_wire()]
336            })))
337            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
338                "choices": [{
339                    "finish_reason": "stop",
340                    "message": {"content": r#"{"name":"Cargo"}"#}
341                }]
342            })))
343            .expect(1)
344            .mount(&server)
345            .await;
346        let model = kimi(&server);
347
348        // Act
349        let tool_response = model
350            .complete(read_request(prompt))
351            .await
352            .expect("initial Kimi tool request should succeed");
353        let call = tool_response
354            .call()
355            .expect("initial response should contain a tool call")
356            .clone();
357        let mut model_request = read_request(prompt);
358        model_request.record_tool_result(call, result.to_string());
359        let response = model
360            .complete(model_request)
361            .await
362            .expect("continued Kimi request should succeed");
363
364        // Assert
365        assert_eq!(response.output(), Some(&json!({ "name": "Cargo" })));
366    }
367
368    #[tokio::test]
369    async fn rejects_missing_and_multiple_tool_calls() {
370        // Arrange
371        let messages = [
372            (json!({"content": null}), "model returned no tool call"),
373            (
374                json!({
375                    "content": null,
376                    "tool_calls": [
377                        {
378                            "id": "call_one",
379                            "type": "function",
380                            "function": {"name": "read", "arguments": r#"{"path":"Cargo.toml"}"#}
381                        },
382                        {
383                            "id": "call_two",
384                            "type": "function",
385                            "function": {"name": "read", "arguments": r#"{"path":"README.md"}"#}
386                        }
387                    ]
388                }),
389                "model returned multiple tool calls",
390            ),
391        ];
392
393        // Act
394        let mut errors = Vec::new();
395        for (message, expected) in messages {
396            let server = MockServer::start().await;
397            mount_tool_response(&server, "inspect the manifest", message).await;
398            let error = kimi(&server)
399                .complete(read_request("inspect the manifest"))
400                .await
401                .expect_err("invalid tool-call count should fail");
402            errors.push((error.to_string(), expected));
403        }
404
405        // Assert
406        assert!(errors.iter().all(|(error, expected)| error == expected));
407    }
408
409    #[tokio::test]
410    async fn rejects_invalid_read_range() {
411        // Arrange
412        let server = MockServer::start().await;
413        mount_tool_response(
414            &server,
415            "inspect the manifest",
416            json!({
417                "content": null,
418                "tool_calls": [{
419                    "id": "call_invalid_limit",
420                    "type": "function",
421                    "function": {
422                        "name": "read",
423                        "arguments": r#"{"path":"Cargo.toml","limit":0}"#
424                    }
425                }]
426            }),
427        )
428        .await;
429        let model = kimi(&server);
430
431        // Act
432        let error = model
433            .complete(read_request("inspect the manifest"))
434            .await
435            .expect_err("zero read limit should fail");
436
437        // Assert
438        assert!(matches!(
439            error,
440            model::ModelError::InvalidToolArguments { .. }
441        ));
442    }
443
444    #[tokio::test]
445    async fn rejects_oversized_read_arguments() {
446        // Arrange
447        let server = MockServer::start().await;
448        let arguments = format!(
449            r#"{{"path":"{}"}}"#,
450            "x".repeat(schema_contract::RESPONSE_CONTENT_LIMIT_BYTES)
451        );
452        mount_tool_response(
453            &server,
454            "inspect the manifest",
455            json!({
456                "content": null,
457                "tool_calls": [{
458                    "id": "call_oversized",
459                    "type": "function",
460                    "function": {"name": "read", "arguments": arguments}
461                }]
462            }),
463        )
464        .await;
465        let model = kimi(&server);
466
467        // Act
468        let error = model
469            .complete(read_request("inspect the manifest"))
470            .await
471            .expect_err("oversized read arguments should fail");
472
473        // Assert
474        assert!(matches!(error, model::ModelError::ResponseContentTooLarge));
475    }
476
477    #[tokio::test]
478    async fn rejects_oversized_reasoning_content() {
479        // Arrange
480        let server = MockServer::start().await;
481        mount_tool_response(
482            &server,
483            "inspect the manifest",
484            json!({
485                "content": null,
486                "reasoning_content":
487                    "x".repeat(schema_contract::RESPONSE_CONTENT_LIMIT_BYTES + 1),
488                "tool_calls": [{
489                    "id": "call_oversized_reasoning",
490                    "type": "function",
491                    "function": {
492                        "name": "read",
493                        "arguments": r#"{"path":"Cargo.toml"}"#
494                    }
495                }]
496            }),
497        )
498        .await;
499        let model = kimi(&server);
500
501        // Act
502        let error = model
503            .complete(read_request("inspect the manifest"))
504            .await
505            .expect_err("oversized reasoning content should fail");
506
507        // Assert
508        assert!(matches!(error, model::ModelError::ResponseContentTooLarge));
509    }
510
511    #[tokio::test]
512    async fn rejects_structured_response_stopped_for_length() {
513        // Arrange
514        let server = MockServer::start().await;
515        Mock::given(method("POST"))
516            .and(path("/chat/completions"))
517            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
518                "choices": [{
519                    "finish_reason": "length",
520                    "message": {"content": r#"{"name":"Ada"}"#}
521                }]
522            })))
523            .mount(&server)
524            .await;
525        let model = kimi(&server);
526
527        // Act
528        let error = model
529            .complete(request("extract the name"))
530            .await
531            .expect_err("truncated response should fail");
532
533        // Assert
534        assert!(matches!(
535            error,
536            model::ModelError::IncompleteResponse { reason } if reason == "length"
537        ));
538    }
539
540    #[tokio::test]
541    async fn bounds_incomplete_response_reason() {
542        // Arrange
543        let server = MockServer::start().await;
544        let finish_reason = "x".repeat(1024);
545        Mock::given(method("POST"))
546            .and(path("/chat/completions"))
547            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
548                "choices": [{
549                    "finish_reason": finish_reason.clone(),
550                    "message": {"content": r#"{"name":"Ada"}"#}
551                }]
552            })))
553            .mount(&server)
554            .await;
555        let model = kimi(&server);
556
557        // Act
558        let error = model
559            .complete(request("extract the name"))
560            .await
561            .expect_err("incomplete response should fail");
562
563        // Assert
564        assert!(matches!(
565            error,
566            model::ModelError::IncompleteResponse { reason }
567                if reason == schema_contract::bounded_diagnostic(finish_reason)
568        ));
569    }
570
571    #[tokio::test]
572    async fn accepts_near_limit_escaped_structured_output() {
573        // Arrange
574        let server = MockServer::start().await;
575        let empty_content =
576            serde_json::to_string(&json!({ "value": "" })).expect("content should serialize");
577        let value =
578            "\\".repeat((schema_contract::RESPONSE_CONTENT_LIMIT_BYTES - empty_content.len()) / 2);
579        let content =
580            serde_json::to_string(&json!({ "value": value })).expect("content should serialize");
581        let body = serde_json::to_vec(&json!({
582            "choices": [{
583                "finish_reason": "stop",
584                "message": {"content": content}
585            }]
586        }))
587        .expect("response should serialize");
588        assert!(schema_contract::RESPONSE_CONTENT_LIMIT_BYTES - content.len() <= 1);
589        assert!(
590            body.len()
591                > schema_contract::RESPONSE_CONTENT_LIMIT_BYTES + RESPONSE_ENVELOPE_LIMIT_BYTES
592        );
593        Mock::given(method("POST"))
594            .and(path("/chat/completions"))
595            .respond_with(ResponseTemplate::new(200).set_body_bytes(body))
596            .mount(&server)
597            .await;
598        let model = kimi(&server);
599
600        // Act
601        let response = model
602            .complete(model::ModelRequest::new(
603                "return escaped content",
604                escaped_value_schema(),
605            ))
606            .await
607            .expect("near-limit escaped output should succeed");
608
609        // Assert
610        assert_eq!(
611            response
612                .output()
613                .expect("response should contain terminal output")
614                .get("value")
615                .and_then(Value::as_str)
616                .map(str::len),
617            Some(value.len())
618        );
619    }
620
621    #[tokio::test]
622    async fn rejects_oversized_success_body_before_decoding() {
623        // Arrange
624        let server = MockServer::start().await;
625        Mock::given(method("POST"))
626            .and(path("/chat/completions"))
627            .respond_with(ResponseTemplate::new(200).set_body_bytes(vec![
628                b'x';
629                SUCCESS_BODY_LIMIT_BYTES
630                    + 1
631            ]))
632            .mount(&server)
633            .await;
634        let model = kimi(&server);
635
636        // Act
637        let error = model
638            .complete(request("hello"))
639            .await
640            .expect_err("oversized successful response should fail");
641
642        // Assert
643        assert!(matches!(error, model::ModelError::ResponseBodyTooLarge));
644    }
645
646    #[tokio::test]
647    async fn rejects_oversized_response_content() {
648        // Arrange
649        let server = MockServer::start().await;
650        let content = "x".repeat(schema_contract::RESPONSE_CONTENT_LIMIT_BYTES + 1);
651        Mock::given(method("POST"))
652            .and(path("/chat/completions"))
653            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
654                "choices": [{
655                    "finish_reason": "stop",
656                    "message": {"content": content}
657                }]
658            })))
659            .mount(&server)
660            .await;
661        let model = kimi(&server);
662
663        // Act
664        let error = model
665            .complete(request("hello"))
666            .await
667            .expect_err("oversized response content should fail");
668
669        // Assert
670        assert!(matches!(error, model::ModelError::ResponseContentTooLarge));
671    }
672
673    #[tokio::test]
674    async fn rejects_schemas_without_explicit_object_root() {
675        // Arrange
676        let server = MockServer::start().await;
677        let model = kimi(&server);
678        let schema_values = [
679            json!({ "type": "array" }),
680            json!({ "not": { "type": "object" } }),
681            json!({
682                "$defs": {
683                    "result": { "type": "object" }
684                },
685                "$ref": "#/$defs/result"
686            }),
687        ];
688
689        // Act
690        let mut errors = Vec::new();
691        for schema_value in schema_values {
692            let schema = crate::OutputSchema::new(schema_value).expect("schema should be valid");
693            errors.push(
694                model
695                    .complete(model::ModelRequest::new("list names", schema))
696                    .await
697                    .expect_err("schema without an explicit object root should fail"),
698            );
699        }
700
701        // Assert
702        assert!(
703            errors
704                .into_iter()
705                .all(|error| matches!(error, model::ModelError::UnsupportedOutputSchema { .. }))
706        );
707        assert!(
708            server
709                .received_requests()
710                .await
711                .expect("request recording should be enabled")
712                .is_empty()
713        );
714    }
715
716    #[tokio::test]
717    async fn rejects_malformed_structured_output() {
718        // Arrange
719        let server = MockServer::start().await;
720        Mock::given(method("POST"))
721            .and(path("/chat/completions"))
722            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
723                "choices": [{
724                    "finish_reason": "stop",
725                    "message": {"content": "not JSON"}
726                }]
727            })))
728            .mount(&server)
729            .await;
730        let model = kimi(&server);
731
732        // Act
733        let error = model
734            .complete(request("extract the name"))
735            .await
736            .expect_err("malformed JSON should fail");
737
738        // Assert
739        assert!(matches!(error, model::ModelError::InvalidJson { .. }));
740    }
741
742    #[tokio::test]
743    async fn rejects_structured_output_schema_violation() {
744        // Arrange
745        let server = MockServer::start().await;
746        Mock::given(method("POST"))
747            .and(path("/chat/completions"))
748            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
749                "choices": [{
750                    "finish_reason": "stop",
751                    "message": {"content": r#"{"name":42}"#}
752                }]
753            })))
754            .mount(&server)
755            .await;
756        let model = kimi(&server);
757
758        // Act
759        let error = model
760            .complete(request("extract the name"))
761            .await
762            .expect_err("schema violation should fail");
763
764        // Assert
765        assert!(matches!(
766            error,
767            model::ModelError::SchemaViolation { path, reason }
768                if path == "/name" && reason.contains("string")
769        ));
770    }
771
772    #[tokio::test]
773    async fn rejects_successful_response_without_choices() {
774        // Arrange
775        let server = MockServer::start().await;
776        Mock::given(method("POST"))
777            .and(path("/chat/completions"))
778            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
779                "choices": []
780            })))
781            .mount(&server)
782            .await;
783        let model = kimi(&server);
784
785        // Act
786        let error = model
787            .complete(request("hello"))
788            .await
789            .expect_err("missing response choice should fail");
790
791        // Assert
792        assert!(matches!(error, model::ModelError::InvalidResponse));
793    }
794
795    #[tokio::test]
796    async fn rejects_successful_response_without_content() {
797        // Arrange
798        let server = MockServer::start().await;
799        Mock::given(method("POST"))
800            .and(path("/chat/completions"))
801            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
802                "choices": [{
803                    "finish_reason": "stop",
804                    "message": {"content": null}
805                }]
806            })))
807            .mount(&server)
808            .await;
809        let model = kimi(&server);
810
811        // Act
812        let error = model
813            .complete(request("hello"))
814            .await
815            .expect_err("missing response content should fail");
816
817        // Assert
818        assert!(matches!(error, model::ModelError::InvalidResponse));
819    }
820
821    #[tokio::test]
822    async fn returns_request_error_for_http_failure() {
823        // Arrange
824        let server = MockServer::start().await;
825        Mock::given(method("POST"))
826            .and(path("/chat/completions"))
827            .respond_with(ResponseTemplate::new(401).set_body_json(json!({
828                "error": {"message": "invalid API key"}
829            })))
830            .mount(&server)
831            .await;
832        let model = kimi(&server);
833
834        // Act
835        let error = model
836            .complete(request("hello"))
837            .await
838            .expect_err("HTTP failure should fail");
839
840        // Assert
841        assert_eq!(
842            error.to_string(),
843            "model request failed: Kimi returned HTTP 401 Unauthorized: \
844             {\"error\":{\"message\":\"invalid API key\"}}"
845        );
846        let provider_error = std::error::Error::source(&error)
847            .expect("HTTP failure should retain its provider error");
848        let source = provider_error
849            .source()
850            .and_then(|source| source.downcast_ref::<reqwest::Error>())
851            .expect("HTTP failure should retain its reqwest source");
852        assert_eq!(source.status(), Some(reqwest::StatusCode::UNAUTHORIZED));
853    }
854
855    #[tokio::test]
856    async fn bounds_http_error_body() {
857        // Arrange
858        let server = MockServer::start().await;
859        Mock::given(method("POST"))
860            .and(path("/chat/completions"))
861            .respond_with(
862                ResponseTemplate::new(500).set_body_string("x".repeat(ERROR_BODY_LIMIT_BYTES + 1)),
863            )
864            .mount(&server)
865            .await;
866        let model = kimi(&server);
867
868        // Act
869        let error = model
870            .complete(request("hello"))
871            .await
872            .expect_err("HTTP failure should fail");
873        let message = error.to_string();
874
875        // Assert
876        assert_eq!(
877            message,
878            format!(
879                "model request failed: Kimi returned HTTP 500 Internal Server Error: {} ...",
880                "x".repeat(ERROR_BODY_LIMIT_BYTES)
881            )
882        );
883    }
884
885    #[tokio::test]
886    async fn returns_request_error_for_malformed_response() {
887        // Arrange
888        let server = MockServer::start().await;
889        Mock::given(method("POST"))
890            .and(path("/chat/completions"))
891            .respond_with(ResponseTemplate::new(200).set_body_string("not JSON"))
892            .mount(&server)
893            .await;
894        let model = kimi(&server);
895
896        // Act
897        let error = model
898            .complete(request("hello"))
899            .await
900            .expect_err("malformed response should fail");
901
902        // Assert
903        assert!(matches!(error, model::ModelError::Request(_)));
904    }
905}