use crate::chat_completion;
pub(crate) const PROVIDER_NAME: &str = "moonshot_ai";
pub(crate) const POLICY: chat_completion::ChatCompletionProviderPolicy =
chat_completion::ChatCompletionProviderPolicy {
display_name: "Kimi",
structured_output: chat_completion::StructuredOutputMode::JsonObject,
telemetry_name: PROVIDER_NAME,
unsupported_schema_reason: "Kimi JSON Object mode requires an explicit object root schema",
};
pub struct KimiConfig {
pub api_key: String,
pub base_url: String,
pub model: String,
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use wiremock::matchers::{bearer_token, body_json, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
use crate::chat_completion::{
ERROR_BODY_LIMIT_BYTES, RESPONSE_ENVELOPE_LIMIT_BYTES, STRUCTURED_OUTPUT_INSTRUCTION,
SUCCESS_BODY_LIMIT_BYTES,
};
use crate::{model, schema_contract, tool};
fn person_schema_value() -> Value {
json!({
"type": "object",
"properties": {
"name": { "type": "string" }
},
"required": ["name"],
"additionalProperties": false
})
}
fn person_schema() -> crate::OutputSchema {
crate::OutputSchema::new(person_schema_value()).expect("schema should be valid")
}
fn request(prompt: &str) -> model::ModelRequest {
model::ModelRequest::new(prompt, person_schema())
}
fn read_request(prompt: &str) -> model::ModelRequest {
request(prompt).with_tool(tool::ToolDefinition::read())
}
fn read_tool_wire() -> Value {
let definition = tool::ToolDefinition::read();
json!({
"type": "function",
"function": {
"description": definition.description(),
"name": definition.name(),
"parameters": definition.parameters()
}
})
}
fn escaped_value_schema() -> crate::OutputSchema {
crate::OutputSchema::new(json!({
"type": "object",
"properties": {
"value": { "type": "string" }
},
"required": ["value"],
"additionalProperties": false
}))
.expect("schema should be valid")
}
fn kimi(server: &MockServer) -> model::ModelClient {
model::ModelClient::kimi(KimiConfig {
api_key: "test-key".to_string(),
base_url: format!("{}/", server.uri()),
model: "kimi-k2.6".to_string(),
})
.expect("fixture configuration should be valid")
}
#[test]
fn metadata_exposes_provider_and_model() {
let model = model::ModelClient::kimi(KimiConfig {
api_key: "test-key".to_string(),
base_url: "https://api.moonshot.example/v1".to_string(),
model: "kimi-k2.6".to_string(),
})
.expect("fixture configuration should be valid");
let metadata = model.metadata();
assert_eq!(metadata.provider(), "moonshot_ai");
assert_eq!(metadata.model(), "kimi-k2.6");
}
#[test]
fn rejects_empty_model_during_construction() {
let config = KimiConfig {
api_key: "test-key".to_string(),
base_url: "https://api.moonshot.example/v1".to_string(),
model: " ".to_string(),
};
let error = model::ModelClient::kimi(config)
.err()
.expect("empty model configuration should be rejected");
assert_eq!(error, model::ModelMetadataError::EmptyModel);
}
async fn mount_structured_response(
server: &MockServer,
prompt: &str,
schema: &Value,
content: &str,
) {
Mock::given(method("POST"))
.and(path("/chat/completions"))
.and(bearer_token("test-key"))
.and(body_json(json!({
"messages": [
{
"content": format!("{STRUCTURED_OUTPUT_INSTRUCTION}{schema}"),
"role": "system"
},
{"content": prompt, "role": "user"}
],
"model": "kimi-k2.6",
"response_format": {"type": "json_object"}
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"choices": [{
"finish_reason": "stop",
"message": {"content": content}
}]
})))
.expect(1)
.mount(server)
.await;
}
async fn mount_tool_response(server: &MockServer, prompt: &str, message: Value) {
Mock::given(method("POST"))
.and(path("/chat/completions"))
.and(bearer_token("test-key"))
.and(body_json(json!({
"messages": [
{
"content": format!(
"{STRUCTURED_OUTPUT_INSTRUCTION}{}",
person_schema_value()
),
"role": "system"
},
{"content": prompt, "role": "user"}
],
"model": "kimi-k2.6",
"response_format": {"type": "json_object"},
"tools": [read_tool_wire()]
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"choices": [{
"finish_reason": "tool_calls",
"message": message
}]
})))
.expect(1)
.mount(server)
.await;
}
#[tokio::test]
async fn accepts_object_root_in_type_array() {
let server = MockServer::start().await;
let schema_value = json!({ "type": ["object"] });
mount_structured_response(&server, "return an object", &schema_value, "{}").await;
let model = kimi(&server);
let schema = crate::OutputSchema::new(schema_value).expect("schema should be valid");
let response = model
.complete(model::ModelRequest::new("return an object", schema))
.await
.expect("Kimi request should succeed");
assert_eq!(response.output(), Some(&json!({})));
}
#[tokio::test]
async fn completes_structured_request() {
let server = MockServer::start().await;
let schema_value = person_schema_value();
mount_structured_response(
&server,
"extract the name",
&schema_value,
r#"{"name":"Ada"}"#,
)
.await;
let model = kimi(&server);
let response = model
.complete(request("extract the name"))
.await
.expect("Kimi request should succeed");
assert_eq!(response.output(), Some(&json!({ "name": "Ada" })));
}
#[tokio::test]
async fn advertises_and_decodes_read_tool_call() {
let server = MockServer::start().await;
mount_tool_response(
&server,
"inspect the manifest",
json!({
"content": null,
"tool_calls": [{
"id": "call_kimi_read",
"type": "function",
"function": {
"name": "read",
"arguments": r#"{"path":"Cargo.toml","offset":1,"limit":12}"#
}
}]
}),
)
.await;
let model = kimi(&server);
let response = model
.complete(read_request("inspect the manifest"))
.await
.expect("Kimi read request should decode");
assert!(response.output().is_none());
let call = response
.call()
.expect("response should contain a tool call");
assert_eq!(call.id(), "call_kimi_read");
assert_eq!(call.name(), "read");
assert_eq!(call.arguments().path(), "Cargo.toml");
assert_eq!(call.arguments().offset(), Some(1));
assert_eq!(call.arguments().limit(), Some(12));
}
#[tokio::test]
async fn rejects_missing_and_multiple_tool_calls() {
let messages = [
(json!({"content": null}), "model returned no tool call"),
(
json!({
"content": null,
"tool_calls": [
{
"id": "call_one",
"type": "function",
"function": {"name": "read", "arguments": r#"{"path":"Cargo.toml"}"#}
},
{
"id": "call_two",
"type": "function",
"function": {"name": "read", "arguments": r#"{"path":"README.md"}"#}
}
]
}),
"model returned multiple tool calls",
),
];
let mut errors = Vec::new();
for (message, expected) in messages {
let server = MockServer::start().await;
mount_tool_response(&server, "inspect the manifest", message).await;
let error = kimi(&server)
.complete(read_request("inspect the manifest"))
.await
.expect_err("invalid tool-call count should fail");
errors.push((error.to_string(), expected));
}
assert!(errors.iter().all(|(error, expected)| error == expected));
}
#[tokio::test]
async fn rejects_invalid_read_range() {
let server = MockServer::start().await;
mount_tool_response(
&server,
"inspect the manifest",
json!({
"content": null,
"tool_calls": [{
"id": "call_invalid_limit",
"type": "function",
"function": {
"name": "read",
"arguments": r#"{"path":"Cargo.toml","limit":0}"#
}
}]
}),
)
.await;
let model = kimi(&server);
let error = model
.complete(read_request("inspect the manifest"))
.await
.expect_err("zero read limit should fail");
assert!(matches!(
error,
model::ModelError::InvalidToolArguments { .. }
));
}
#[tokio::test]
async fn rejects_oversized_read_arguments() {
let server = MockServer::start().await;
let arguments = format!(
r#"{{"path":"{}"}}"#,
"x".repeat(schema_contract::RESPONSE_CONTENT_LIMIT_BYTES)
);
mount_tool_response(
&server,
"inspect the manifest",
json!({
"content": null,
"tool_calls": [{
"id": "call_oversized",
"type": "function",
"function": {"name": "read", "arguments": arguments}
}]
}),
)
.await;
let model = kimi(&server);
let error = model
.complete(read_request("inspect the manifest"))
.await
.expect_err("oversized read arguments should fail");
assert!(matches!(error, model::ModelError::ResponseContentTooLarge));
}
#[tokio::test]
async fn rejects_structured_response_stopped_for_length() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"choices": [{
"finish_reason": "length",
"message": {"content": r#"{"name":"Ada"}"#}
}]
})))
.mount(&server)
.await;
let model = kimi(&server);
let error = model
.complete(request("extract the name"))
.await
.expect_err("truncated response should fail");
assert!(matches!(
error,
model::ModelError::IncompleteResponse { reason } if reason == "length"
));
}
#[tokio::test]
async fn bounds_incomplete_response_reason() {
let server = MockServer::start().await;
let finish_reason = "x".repeat(1024);
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"choices": [{
"finish_reason": finish_reason.clone(),
"message": {"content": r#"{"name":"Ada"}"#}
}]
})))
.mount(&server)
.await;
let model = kimi(&server);
let error = model
.complete(request("extract the name"))
.await
.expect_err("incomplete response should fail");
assert!(matches!(
error,
model::ModelError::IncompleteResponse { reason }
if reason == schema_contract::bounded_diagnostic(finish_reason)
));
}
#[tokio::test]
async fn accepts_near_limit_escaped_structured_output() {
let server = MockServer::start().await;
let empty_content =
serde_json::to_string(&json!({ "value": "" })).expect("content should serialize");
let value =
"\\".repeat((schema_contract::RESPONSE_CONTENT_LIMIT_BYTES - empty_content.len()) / 2);
let content =
serde_json::to_string(&json!({ "value": value })).expect("content should serialize");
let body = serde_json::to_vec(&json!({
"choices": [{
"finish_reason": "stop",
"message": {"content": content}
}]
}))
.expect("response should serialize");
assert!(schema_contract::RESPONSE_CONTENT_LIMIT_BYTES - content.len() <= 1);
assert!(
body.len()
> schema_contract::RESPONSE_CONTENT_LIMIT_BYTES + RESPONSE_ENVELOPE_LIMIT_BYTES
);
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(body))
.mount(&server)
.await;
let model = kimi(&server);
let response = model
.complete(model::ModelRequest::new(
"return escaped content",
escaped_value_schema(),
))
.await
.expect("near-limit escaped output should succeed");
assert_eq!(
response
.output()
.expect("response should contain terminal output")
.get("value")
.and_then(Value::as_str)
.map(str::len),
Some(value.len())
);
}
#[tokio::test]
async fn rejects_oversized_success_body_before_decoding() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(vec![
b'x';
SUCCESS_BODY_LIMIT_BYTES
+ 1
]))
.mount(&server)
.await;
let model = kimi(&server);
let error = model
.complete(request("hello"))
.await
.expect_err("oversized successful response should fail");
assert!(matches!(error, model::ModelError::ResponseBodyTooLarge));
}
#[tokio::test]
async fn rejects_oversized_response_content() {
let server = MockServer::start().await;
let content = "x".repeat(schema_contract::RESPONSE_CONTENT_LIMIT_BYTES + 1);
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"choices": [{
"finish_reason": "stop",
"message": {"content": content}
}]
})))
.mount(&server)
.await;
let model = kimi(&server);
let error = model
.complete(request("hello"))
.await
.expect_err("oversized response content should fail");
assert!(matches!(error, model::ModelError::ResponseContentTooLarge));
}
#[tokio::test]
async fn rejects_schemas_without_explicit_object_root() {
let server = MockServer::start().await;
let model = kimi(&server);
let schema_values = [
json!({ "type": "array" }),
json!({ "not": { "type": "object" } }),
json!({
"$defs": {
"result": { "type": "object" }
},
"$ref": "#/$defs/result"
}),
];
let mut errors = Vec::new();
for schema_value in schema_values {
let schema = crate::OutputSchema::new(schema_value).expect("schema should be valid");
errors.push(
model
.complete(model::ModelRequest::new("list names", schema))
.await
.expect_err("schema without an explicit object root should fail"),
);
}
assert!(
errors
.into_iter()
.all(|error| matches!(error, model::ModelError::UnsupportedOutputSchema { .. }))
);
assert!(
server
.received_requests()
.await
.expect("request recording should be enabled")
.is_empty()
);
}
#[tokio::test]
async fn rejects_malformed_structured_output() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"choices": [{
"finish_reason": "stop",
"message": {"content": "not JSON"}
}]
})))
.mount(&server)
.await;
let model = kimi(&server);
let error = model
.complete(request("extract the name"))
.await
.expect_err("malformed JSON should fail");
assert!(matches!(error, model::ModelError::InvalidJson { .. }));
}
#[tokio::test]
async fn rejects_structured_output_schema_violation() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"choices": [{
"finish_reason": "stop",
"message": {"content": r#"{"name":42}"#}
}]
})))
.mount(&server)
.await;
let model = kimi(&server);
let error = model
.complete(request("extract the name"))
.await
.expect_err("schema violation should fail");
assert!(matches!(
error,
model::ModelError::SchemaViolation { path, reason }
if path == "/name" && reason.contains("string")
));
}
#[tokio::test]
async fn rejects_successful_response_without_choices() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"choices": []
})))
.mount(&server)
.await;
let model = kimi(&server);
let error = model
.complete(request("hello"))
.await
.expect_err("missing response choice should fail");
assert!(matches!(error, model::ModelError::InvalidResponse));
}
#[tokio::test]
async fn rejects_successful_response_without_content() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"choices": [{
"finish_reason": "stop",
"message": {"content": null}
}]
})))
.mount(&server)
.await;
let model = kimi(&server);
let error = model
.complete(request("hello"))
.await
.expect_err("missing response content should fail");
assert!(matches!(error, model::ModelError::InvalidResponse));
}
#[tokio::test]
async fn returns_request_error_for_http_failure() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
"error": {"message": "invalid API key"}
})))
.mount(&server)
.await;
let model = kimi(&server);
let error = model
.complete(request("hello"))
.await
.expect_err("HTTP failure should fail");
assert_eq!(
error.to_string(),
"model request failed: Kimi returned HTTP 401 Unauthorized: \
{\"error\":{\"message\":\"invalid API key\"}}"
);
let provider_error = std::error::Error::source(&error)
.expect("HTTP failure should retain its provider error");
let source = provider_error
.source()
.and_then(|source| source.downcast_ref::<reqwest::Error>())
.expect("HTTP failure should retain its reqwest source");
assert_eq!(source.status(), Some(reqwest::StatusCode::UNAUTHORIZED));
}
#[tokio::test]
async fn bounds_http_error_body() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(
ResponseTemplate::new(500).set_body_string("x".repeat(ERROR_BODY_LIMIT_BYTES + 1)),
)
.mount(&server)
.await;
let model = kimi(&server);
let error = model
.complete(request("hello"))
.await
.expect_err("HTTP failure should fail");
let message = error.to_string();
assert_eq!(
message,
format!(
"model request failed: Kimi returned HTTP 500 Internal Server Error: {} ...",
"x".repeat(ERROR_BODY_LIMIT_BYTES)
)
);
}
#[tokio::test]
async fn returns_request_error_for_malformed_response() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_string("not JSON"))
.mount(&server)
.await;
let model = kimi(&server);
let error = model
.complete(request("hello"))
.await
.expect_err("malformed response should fail");
assert!(matches!(error, model::ModelError::Request(_)));
}
}