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
15pub struct KimiConfig {
18 pub api_key: String,
20 pub base_url: String,
22 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 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 let metadata = model.metadata();
108
109 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 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 let error = model::ModelClient::kimi(config)
125 .err()
126 .expect("empty model configuration should be rejected");
127
128 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 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 let response = model
204 .complete(model::ModelRequest::new("return an object", schema))
205 .await
206 .expect("Kimi request should succeed");
207
208 assert_eq!(response.output(), Some(&json!({})));
210 }
211
212 #[tokio::test]
213 async fn completes_structured_request() {
214 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 let response = model
228 .complete(request("extract the name"))
229 .await
230 .expect("Kimi request should succeed");
231
232 assert_eq!(response.output(), Some(&json!({ "name": "Ada" })));
234 }
235
236 #[tokio::test]
237 async fn advertises_and_decodes_read_tool_call() {
238 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 let response = model
260 .complete(read_request("inspect the manifest"))
261 .await
262 .expect("Kimi read request should decode");
263
264 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 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 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_eq!(response.output(), Some(&json!({ "name": "Cargo" })));
366 }
367
368 #[tokio::test]
369 async fn rejects_missing_and_multiple_tool_calls() {
370 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 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!(errors.iter().all(|(error, expected)| error == expected));
407 }
408
409 #[tokio::test]
410 async fn rejects_invalid_read_range() {
411 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 let error = model
433 .complete(read_request("inspect the manifest"))
434 .await
435 .expect_err("zero read limit should fail");
436
437 assert!(matches!(
439 error,
440 model::ModelError::InvalidToolArguments { .. }
441 ));
442 }
443
444 #[tokio::test]
445 async fn rejects_oversized_read_arguments() {
446 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 let error = model
469 .complete(read_request("inspect the manifest"))
470 .await
471 .expect_err("oversized read arguments should fail");
472
473 assert!(matches!(error, model::ModelError::ResponseContentTooLarge));
475 }
476
477 #[tokio::test]
478 async fn rejects_oversized_reasoning_content() {
479 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 let error = model
503 .complete(read_request("inspect the manifest"))
504 .await
505 .expect_err("oversized reasoning content should fail");
506
507 assert!(matches!(error, model::ModelError::ResponseContentTooLarge));
509 }
510
511 #[tokio::test]
512 async fn rejects_structured_response_stopped_for_length() {
513 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 let error = model
529 .complete(request("extract the name"))
530 .await
531 .expect_err("truncated response should fail");
532
533 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 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 let error = model
559 .complete(request("extract the name"))
560 .await
561 .expect_err("incomplete response should fail");
562
563 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 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 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_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 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 let error = model
638 .complete(request("hello"))
639 .await
640 .expect_err("oversized successful response should fail");
641
642 assert!(matches!(error, model::ModelError::ResponseBodyTooLarge));
644 }
645
646 #[tokio::test]
647 async fn rejects_oversized_response_content() {
648 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 let error = model
665 .complete(request("hello"))
666 .await
667 .expect_err("oversized response content should fail");
668
669 assert!(matches!(error, model::ModelError::ResponseContentTooLarge));
671 }
672
673 #[tokio::test]
674 async fn rejects_schemas_without_explicit_object_root() {
675 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 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!(
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 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 let error = model
734 .complete(request("extract the name"))
735 .await
736 .expect_err("malformed JSON should fail");
737
738 assert!(matches!(error, model::ModelError::InvalidJson { .. }));
740 }
741
742 #[tokio::test]
743 async fn rejects_structured_output_schema_violation() {
744 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 let error = model
760 .complete(request("extract the name"))
761 .await
762 .expect_err("schema violation should fail");
763
764 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 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 let error = model
787 .complete(request("hello"))
788 .await
789 .expect_err("missing response choice should fail");
790
791 assert!(matches!(error, model::ModelError::InvalidResponse));
793 }
794
795 #[tokio::test]
796 async fn rejects_successful_response_without_content() {
797 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 let error = model
813 .complete(request("hello"))
814 .await
815 .expect_err("missing response content should fail");
816
817 assert!(matches!(error, model::ModelError::InvalidResponse));
819 }
820
821 #[tokio::test]
822 async fn returns_request_error_for_http_failure() {
823 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 let error = model
836 .complete(request("hello"))
837 .await
838 .expect_err("HTTP failure should fail");
839
840 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 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 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_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 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 let error = model
898 .complete(request("hello"))
899 .await
900 .expect_err("malformed response should fail");
901
902 assert!(matches!(error, model::ModelError::Request(_)));
904 }
905}