Skip to main content

ag_harness/provider/
qwen.rs

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
15/// Configuration for a Qwen model served through Alibaba Cloud Model Studio's
16/// OpenAI-compatible API.
17pub struct QwenConfig {
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    /// Qwen model identifier sent with each request.
23    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        // Arrange
124        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        // Act
131        let error = model::ModelClient::qwen(config)
132            .err()
133            .expect("empty model configuration should be rejected");
134
135        // Assert
136        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        // Arrange
210        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        // Act
222        let response = model
223            .complete(request("extract the name"))
224            .await
225            .expect("Qwen request should succeed");
226
227        // Assert
228        assert_eq!(response.output(), Some(&json!({ "name": "Ada" })));
229    }
230
231    #[tokio::test]
232    async fn advertises_and_decodes_read_tool_call() {
233        // Arrange
234        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        // Act
254        let response = model
255            .complete(read_request("inspect the manifest"))
256            .await
257            .expect("Qwen read request should decode");
258
259        // Assert
260        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        // Arrange
274        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        // Act
336        let response = model
337            .complete(model_request)
338            .await
339            .expect("continued Qwen request should succeed");
340
341        // Assert
342        assert_eq!(response.output(), Some(&json!({ "name": "Cargo" })));
343    }
344
345    #[tokio::test]
346    async fn rejects_terminal_response_with_tool_calls() {
347        // Arrange
348        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        // Act
369        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
375        assert!(matches!(
376            error,
377            model::ModelError::TerminalResponseWithToolCalls
378        ));
379    }
380
381    #[tokio::test]
382    async fn rejects_malformed_and_invalid_read_arguments() {
383        // Arrange
384        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        // Act
397        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
421        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        // Arrange
431        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        // Act
477        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
489        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        // Arrange
497        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        // Act
506        let output = model
507            .generate(&request("extract the name"))
508            .await
509            .expect("stubbed Qwen request should succeed");
510
511        // Assert
512        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        // Arrange
522        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        // Act
536        let error = model
537            .complete(request("extract the name"))
538            .await
539            .expect_err("truncated response should fail");
540
541        // Assert
542        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        // Arrange
551        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        // Act
566        let error = model
567            .complete(request("extract the name"))
568            .await
569            .expect_err("incomplete response should fail");
570
571        // Assert
572        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        // Arrange
582        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        // Act
609        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
618        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        // Arrange
632        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        // Act
645        let error = model
646            .complete(request("hello"))
647            .await
648            .expect_err("oversized successful response should fail");
649
650        // Assert
651        assert!(matches!(error, model::ModelError::ResponseBodyTooLarge));
652    }
653
654    #[tokio::test]
655    async fn rejects_oversized_response_content() {
656        // Arrange
657        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        // Act
672        let error = model
673            .complete(request("hello"))
674            .await
675            .expect_err("oversized response content should fail");
676
677        // Assert
678        assert!(matches!(error, model::ModelError::ResponseContentTooLarge));
679    }
680
681    #[tokio::test]
682    async fn rejects_schemas_without_explicit_object_root() {
683        // Arrange
684        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        // Act
698        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
710        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        // Arrange
727        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        // Act
741        let error = model
742            .complete(request("extract the name"))
743            .await
744            .expect_err("malformed JSON should fail");
745
746        // Assert
747        assert!(matches!(error, model::ModelError::InvalidJson { .. }));
748    }
749
750    #[tokio::test]
751    async fn rejects_structured_output_schema_violation() {
752        // Arrange
753        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        // Act
767        let error = model
768            .complete(request("extract the name"))
769            .await
770            .expect_err("schema violation should fail");
771
772        // Assert
773        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        // Arrange
783        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        // Act
794        let error = model
795            .complete(request("hello"))
796            .await
797            .expect_err("missing response content should fail");
798
799        // Assert
800        assert!(matches!(error, model::ModelError::InvalidResponse));
801    }
802
803    #[tokio::test]
804    async fn returns_request_error_for_http_failure() {
805        // Arrange
806        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        // Act
817        let error = model
818            .complete(request("hello"))
819            .await
820            .expect_err("HTTP failure should fail");
821
822        // Assert
823        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        // Arrange
840        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        // Act
851        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
858        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        // Arrange
870        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        // Act
879        let error = model
880            .complete(request("hello"))
881            .await
882            .expect_err("malformed response should fail");
883
884        // Assert
885        assert!(matches!(error, model::ModelError::Request(_)));
886    }
887}