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