diff --git a/.gitignore b/.gitignore index 15425d1..9800941 100644 --- a/.gitignore +++ b/.gitignore @@ -171,4 +171,6 @@ cython_debug/ .pypirc # acli -.acli/ +.acli + +.idea diff --git a/dashscope/aigc/code_generation.py b/dashscope/aigc/code_generation.py index a5f819f..4dbf422 100644 --- a/dashscope/aigc/code_generation.py +++ b/dashscope/aigc/code_generation.py @@ -152,6 +152,7 @@ def call( # type: ignore[override] api_key=api_key, input=input, workspace=workspace, + is_service=False, **parameters, ) diff --git a/dashscope/api_entities/aiohttp_request.py b/dashscope/api_entities/aiohttp_request.py index e5487b5..d220ffb 100644 --- a/dashscope/api_entities/aiohttp_request.py +++ b/dashscope/api_entities/aiohttp_request.py @@ -57,7 +57,7 @@ def __init__( self.async_request = async_request self._external_aio_session = session self.headers = { - "Accept": "application/json", + "Accept": "application/json; charset=utf-8", "Authorization": f"Bearer {api_key}", "Cache-Control": "no-cache", **self.headers, # type: ignore[has-type] @@ -70,7 +70,7 @@ def __init__( } self.method = http_method if self.method == HTTPMethod.POST: - self.headers["Content-Type"] = "application/json" + self.headers["Content-Type"] = "application/json; charset=utf-8" self.stream = stream if self.stream: @@ -286,10 +286,13 @@ async def _handle_request(self): timeout=request_timeout, ) else: + body = json.dumps(obj, ensure_ascii=False).encode( + "utf-8", + ) response = await session.request( "POST", url=self.url, - json=obj, + data=body, headers=self.headers, timeout=request_timeout, ) diff --git a/dashscope/api_entities/api_request_data.py b/dashscope/api_entities/api_request_data.py index aee5664..50ab01c 100644 --- a/dashscope/api_entities/api_request_data.py +++ b/dashscope/api_entities/api_request_data.py @@ -83,8 +83,14 @@ def get_aiohttp_payload(self): form.add_field(key, value) form.add_field("model", data["model"]) if "input" in data: - form.add_field("input", json.dumps(data["input"])) - form.add_field("parameters", json.dumps(data["parameters"])) + form.add_field( + "input", + json.dumps(data["input"], ensure_ascii=False), + ) + form.add_field( + "parameters", + json.dumps(data["parameters"], ensure_ascii=False), + ) return True, form() # pylint: disable=unreachable,pointless-string-statement """ diff --git a/dashscope/api_entities/http_request.py b/dashscope/api_entities/http_request.py index 1208bbc..285c6ab 100644 --- a/dashscope/api_entities/http_request.py +++ b/dashscope/api_entities/http_request.py @@ -90,7 +90,7 @@ def __init__( self._external_session = None self._external_aio_session = None self.headers: Dict = { - "Accept": "application/json", + "Accept": "application/json; charset=utf-8", "Authorization": f"Bearer {api_key}", **self.headers, } @@ -115,7 +115,7 @@ def __init__( } self.method = http_method if self.method == HTTPMethod.POST: - self.headers["Content-Type"] = "application/json" + self.headers["Content-Type"] = "application/json; charset=utf-8" self.stream = stream if self.stream: @@ -194,10 +194,13 @@ async def _handle_aio_request(self): # pylint: disable=too-many-branches timeout=request_timeout, ) else: + body = json.dumps(obj, ensure_ascii=False).encode( + "utf-8", + ) response = await session.request( "POST", url=self.url, - json=obj, + data=body, headers=self.headers, timeout=request_timeout, ) @@ -483,10 +486,13 @@ def _handle_request(self): # pylint: disable=too-many-branches ) else: logger.debug("Request body: %s", obj) + body = json.dumps(obj, ensure_ascii=False).encode( + "utf-8", + ) response = session.post( url=self.url, stream=self.stream, - json=obj, + data=body, headers={**self.headers}, timeout=self.timeout, ) diff --git a/dashscope/api_entities/websocket_request.py b/dashscope/api_entities/websocket_request.py index 249b8d8..a3ceb97 100644 --- a/dashscope/api_entities/websocket_request.py +++ b/dashscope/api_entities/websocket_request.py @@ -73,7 +73,7 @@ def __init__( self.headers = { "Authorization": f"bearer {api_key}", - **self.headers, + **self.headers, # type: ignore[has-type] } self.task_headers = { @@ -422,4 +422,4 @@ async def _check_websocket_unexpected_message(self, msg): def _build_up_message(self, headers, payload): message = {"header": headers, "payload": payload} - return json.dumps(message) + return json.dumps(message, ensure_ascii=False) diff --git a/dashscope/cli/agentic_rl.py b/dashscope/cli/agentic_rl.py index ece3953..34d5c1d 100644 --- a/dashscope/cli/agentic_rl.py +++ b/dashscope/cli/agentic_rl.py @@ -670,7 +670,7 @@ def logs( ) format_output( - {"job_id": job_id, "logs": result.output.get("logs", "")}, + {"job_id": job_id, "logs": getattr(result.output, "logs", "")}, fmt=output_format, ) except Exception as e: diff --git a/dashscope/cli/deployments.py b/dashscope/cli/deployments.py index d17bdd0..6664c73 100644 --- a/dashscope/cli/deployments.py +++ b/dashscope/cli/deployments.py @@ -43,7 +43,7 @@ def _wait_for_deployment(deployed_model: str): while True: rsp = dashscope.Deployments.get(deployed_model) output = ensure_ok(rsp) - status = output["status"] + status = output.status if status in ( DeploymentStatus.PENDING, @@ -67,18 +67,14 @@ def _wait_for_deployment(deployed_model: str): def _print_deployments(output): """Pretty-print a list of deployments from *output*.""" - if ( - output is None - or "deployments" not in output - or not output["deployments"] - ): + if output is None or not output.deployments: console.print("There is no deployed model!") return - for dep in output["deployments"]: + for dep in output.deployments: console.print( - f"Deployed_model: {dep['deployed_model']}, " - f"model: {dep['model_name']}, " - f"status: {dep['status']}", + f"Deployed_model: {dep.deployed_model}, " + f"model: {dep.model_name}, " + f"status: {dep.status}", ) @@ -111,7 +107,7 @@ def create( suffix=suffix, # type: ignore[arg-type] ) output = ensure_ok(rsp) - deployed_model = output["deployed_model"] + deployed_model = output.deployed_model success(f"Create model: {deployed_model} deployment") _wait_for_deployment(deployed_model) @@ -151,9 +147,9 @@ def get( rsp = dashscope.Deployments.get(deployed_model) output = ensure_ok(rsp) console.print( - f"Deployed model: {output['deployed_model']} " - f"capacity: {output['capacity']} " - f"status: {output['status']}", + f"Deployed model: {output.deployed_model} " + f"capacity: {output.capacity} " + f"status: {output.status}", ) @@ -166,7 +162,7 @@ def list_deployments( """List model deployments.""" rsp = dashscope.Deployments.list(page_no=page, page_size=size) output = ensure_ok(rsp) - if output is None or not output.get("deployments"): + if output is None or not output.deployments: console.print("There is no deployed model.") return _print_deployments(output) @@ -193,9 +189,9 @@ def scale( console.print("There is no deployed model.") return console.print( - f"Deployed_model: {output['deployed_model']}, " - f"model: {output['model_name']}, " - f"status: {output['status']}", + f"Deployed_model: {output.deployed_model}, " + f"model: {output.model_name}, " + f"status: {output.status}", ) diff --git a/dashscope/cli/fine_tunes.py b/dashscope/cli/fine_tunes.py index cdd44df..0a7f60d 100644 --- a/dashscope/cli/fine_tunes.py +++ b/dashscope/cli/fine_tunes.py @@ -45,7 +45,7 @@ def _wait_for_job(job_id: str): while True: rsp = dashscope.FineTunes.get(job_id) output = ensure_ok(rsp) - status = output["status"] + status = output.status if status == TaskStatus.FAILED: err_console.print("[red]Fine-tune FAILED![/red]") @@ -62,7 +62,7 @@ def _wait_for_job(job_id: str): if status == TaskStatus.SUCCEEDED: success( f"Fine-tune task success, fine-tuned model: " - f"{output['finetuned_output']}", + f"{output.finetuned_output}", ) return @@ -86,12 +86,12 @@ def _stream_events(job_id: str): print_failed_message(rsp) return - if rsp.output["status"] in ( + if rsp.output.status in ( TaskStatus.FAILED, TaskStatus.CANCELED, TaskStatus.SUCCEEDED, ): - console.print(f"Fine-tune job: {job_id} is {rsp.output['status']}") + console.print(f"Fine-tune job: {job_id} is {rsp.output.status}") _dump_logs(job_id) return @@ -120,9 +120,9 @@ def _dump_logs(job_id: str): line=LOG_PAGE_SIZE, ) output = ensure_ok(rsp) - for line in output["logs"]: + for line in output.logs: console.print(line, highlight=False) - if len(output["logs"]) < LOG_PAGE_SIZE: + if len(output.logs) < LOG_PAGE_SIZE: break offset += LOG_PAGE_SIZE @@ -202,7 +202,7 @@ def create( hyper_parameters=params if params else None, # type: ignore[arg-type] ) output = ensure_ok(rsp) - job_id = output["job_id"] + job_id = output.job_id success(f"Create fine-tune job success, job_id: {job_id}") _wait_for_job(job_id) @@ -280,14 +280,14 @@ def get( """Get fine-tune job status.""" rsp = dashscope.FineTunes.get(job_id) output = ensure_ok(rsp) - status = output["status"] + status = output.status if status == TaskStatus.FAILED: err_console.print("[red]Fine-tune failed![/red]") elif status == TaskStatus.CANCELED: console.print("Fine-tune task canceled") elif status == TaskStatus.SUCCEEDED: - model_name = output["finetuned_output"] + model_name = output.finetuned_output success(f"Fine-tune task success, fine-tuned model: {model_name}") else: console.print(f"The fine-tune task is: {status}") @@ -302,17 +302,17 @@ def list_jobs( """List fine-tune jobs.""" rsp = dashscope.FineTunes.list(page=page, page_size=size) output = ensure_ok(rsp) - if output is None or not output.get("jobs"): + if output is None or not output.jobs: console.print("There is no fine-tuned model.") return - for job in output["jobs"]: + for job in output.jobs: line = ( - f"job: {job['job_id']}, status: {job['status']}, " - f"base model: {job['model']}" + f"job: {job.job_id}, status: {job.status}, " + f"base model: {job.model}" ) - if job["status"] == TaskStatus.SUCCEEDED: - line += f", fine-tuned model: {job['finetuned_output']}" + if job.status == TaskStatus.SUCCEEDED: + line += f", fine-tuned model: {job.finetuned_output}" console.print(line) diff --git a/dashscope/client/base_api.py b/dashscope/client/base_api.py index d67149b..fc067fc 100644 --- a/dashscope/client/base_api.py +++ b/dashscope/client/base_api.py @@ -2,6 +2,7 @@ # Copyright (c) Alibaba, Inc. and its affiliates. import asyncio import collections +import json import time from http import HTTPStatus from typing import Any, Dict, Iterator, List, Union @@ -1266,11 +1267,13 @@ def call( flattened_output = kwargs.pop("flattened_output", False) with requests.Session() as session: logger.debug("Starting request: %s", url) + body = json.dumps(data, ensure_ascii=False).encode("utf-8") response = session.post( url, - json=data, + data=body, stream=stream, headers={ + "Content-Type": "application/json; charset=utf-8", **_workspace_header(workspace), **default_headers(api_key), **kwargs.pop("headers", {}), @@ -1295,7 +1298,7 @@ class UpdateMixin: def update( cls, target: str, - json: object, + json: object, # pylint: disable=redefined-outer-name api_key: str = None, path: str = None, workspace: str = None, @@ -1327,13 +1330,17 @@ def update( DEFAULT_REQUEST_TIMEOUT_SECONDS, ) flattened_output = kwargs.pop("flattened_output", False) + import json as _json # pylint: disable=reimported + with requests.Session() as session: logger.debug("Starting request: %s", url) + body = _json.dumps(json, ensure_ascii=False).encode("utf-8") if method == "post": response = session.post( url, - json=json, + data=body, headers={ + "Content-Type": "application/json; charset=utf-8", **_workspace_header(workspace), **default_headers(api_key), **kwargs.pop("headers", {}), @@ -1343,8 +1350,9 @@ def update( else: response = session.patch( url, - json=json, + data=body, headers={ + "Content-Type": "application/json; charset=utf-8", **_workspace_header(workspace), **default_headers(api_key), **kwargs.pop("headers", {}), @@ -1360,7 +1368,7 @@ class PutMixin: def put( cls, target: str, - json: object, + json: object, # pylint: disable=redefined-outer-name path: str = None, api_key: str = None, workspace: str = None, @@ -1390,12 +1398,16 @@ def put( REQUEST_TIMEOUT_KEYWORD, DEFAULT_REQUEST_TIMEOUT_SECONDS, ) + import json as _json # pylint: disable=reimported + with requests.Session() as session: logger.debug("Starting request: %s", url) + body = _json.dumps(json, ensure_ascii=False).encode("utf-8") response = session.put( url, - json=json, + data=body, headers={ + "Content-Type": "application/json; charset=utf-8", **_workspace_header(workspace), **default_headers(api_key), **kwargs.pop("headers", {}), diff --git a/dashscope/common/utils.py b/dashscope/common/utils.py index 1e89905..7a87125 100644 --- a/dashscope/common/utils.py +++ b/dashscope/common/utils.py @@ -166,7 +166,7 @@ def default_headers(api_key: str = None) -> Dict[str, str]: if api_key is None: api_key = get_default_api_key() headers["Authorization"] = f"Bearer {api_key}" - headers["Accept"] = "application/json" + headers["Accept"] = "application/json; charset=utf-8" return headers diff --git a/dashscope/nlp/understanding.py b/dashscope/nlp/understanding.py index 137f0a4..e6be294 100644 --- a/dashscope/nlp/understanding.py +++ b/dashscope/nlp/understanding.py @@ -66,6 +66,7 @@ def call( # type: ignore[override] function=function, api_key=api_key, input=input, + is_service=False, **parameters, ) diff --git a/tests/integration/test_large_utf8_payload.py b/tests/integration/test_large_utf8_payload.py new file mode 100644 index 0000000..3ab07c7 --- /dev/null +++ b/tests/integration/test_large_utf8_payload.py @@ -0,0 +1,207 @@ +# -*- coding: utf-8 -*- +"""Integration test: send websearch.json to real DashScope model. + +Usage: + export DASHSCOPE_API_KEY="sk-xxx" + python3 tests/integration/test_websearch_utf8.py +""" + +import asyncio +import json +import os +import time + +from dashscope.aigc.generation import Generation, AioGeneration + +WEBSEARCH_JSON = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + "websearch.json", +) + +MODEL = "qwen3.7-max" +MAX_OUTPUT_TOKENS = 1280 + + +def _print_result(resp, elapsed): # pylint: disable=unused-argument + print(f"Status: {resp.status_code}") + print(f"Request ID: {resp.request_id}") + print(f"Elapsed: {elapsed:.2f}s") + + if resp.status_code == 200: + if hasattr(resp, "output") and resp.output: + choices = getattr(resp.output, "choices", None) + if choices: + text = choices[0].get("message", {}).get("content", "") + print(f"Output: {len(text)} chars") + print(f"Preview: {text[:300]}") + if hasattr(resp, "usage") and resp.usage: + print(f"Input tokens: {resp.usage.get('input_tokens', 'N/A')}") + print(f"Output tokens: {resp.usage.get('output_tokens', 'N/A')}") + print("\nPASSED") + return True + else: + print(f"Code: {getattr(resp, 'code', '')}") + print(f"Message: {getattr(resp, 'message', '')}") + print("\nFAILED") + return False + + +def test_sync(messages): + print("\n" + "=" * 60) + print("SYNC TEST") + print("=" * 60) + start = time.time() + try: + resp = Generation.call( + model=MODEL, + messages=messages, + max_tokens=MAX_OUTPUT_TOKENS, + result_format="message", + ) + except Exception as e: + elapsed = time.time() - start + print(f"EXCEPTION after {elapsed:.2f}s: {e}") + import traceback + + traceback.print_exc() + return False + + elapsed = time.time() - start + return _print_result(resp, elapsed) + + +async def test_async(messages): + print("\n" + "=" * 60) + print("ASYNC TEST") + print("=" * 60) + start = time.time() + try: + resp = await AioGeneration.call( + model=MODEL, + messages=messages, + max_tokens=MAX_OUTPUT_TOKENS, + result_format="message", + ) + except Exception as e: + elapsed = time.time() - start + print(f"EXCEPTION after {elapsed:.2f}s: {e}") + import traceback + + traceback.print_exc() + return False + + elapsed = time.time() - start + return _print_result(resp, elapsed) + + +def test_stream(messages): + print("\n" + "=" * 60) + print("STREAM TEST") + print("=" * 60) + start = time.time() + full_text = "" + try: + responses = Generation.call( + model=MODEL, + messages=messages, + max_tokens=MAX_OUTPUT_TOKENS, + result_format="message", + stream=True, + incremental_output=True, + ) + for resp in responses: + if resp.status_code != 200: + elapsed = time.time() - start + return _print_result(resp, elapsed) + if hasattr(resp, "output") and resp.output: + choices = getattr(resp.output, "choices", None) + if choices: + delta = choices[0].get("message", {}).get("content", "") + full_text += delta + except Exception as e: + elapsed = time.time() - start + print(f"EXCEPTION after {elapsed:.2f}s: {e}") + import traceback + + traceback.print_exc() + return False + + elapsed = time.time() - start + print("Status: 200") + print(f"Elapsed: {elapsed:.2f}s") + print(f"Output: {len(full_text)} chars") + if full_text: + print(f"Preview: {full_text[:300]}") + print("\nPASSED") + return True + + +def test_websocket(messages): + # WebSocket has a smaller message size limit than HTTP; + # use a subset that still contains non-ASCII content. + ws_messages = messages[:5] + print("\n" + "=" * 60) + print( + f"WEBSOCKET TEST ({len(ws_messages)}/{len(messages)} messages)", + ) + print("=" * 60) + start = time.time() + try: + resp = Generation.call( + model=MODEL, + messages=ws_messages, + max_tokens=MAX_OUTPUT_TOKENS, + result_format="message", + api_protocol="websocket", + ) + except Exception as e: + elapsed = time.time() - start + print(f"EXCEPTION after {elapsed:.2f}s: {e}") + import traceback + + traceback.print_exc() + return False + + elapsed = time.time() - start + return _print_result(resp, elapsed) + + +def main(): + with open(WEBSEARCH_JSON, "r", encoding="utf-8") as f: + data = json.load(f) + + # websearch.json stores some fields as JSON strings; parse them so the API + # schema (which expects objects) is satisfied. + for msg in data["messages"]: + for key in ("extra", "files", "childrenIds"): + if key in msg and isinstance(msg[key], str): + try: + msg[key] = json.loads(msg[key]) + except json.JSONDecodeError: + pass + + messages = data["messages"] + + print(f"Model: {MODEL}") + print(f"Messages: {len(messages)}") + print( + f"API URL: {os.environ.get('DASHSCOPE_HTTP_BASE_URL', 'default')}", + ) + print(f"API Key: {os.environ.get('DASHSCOPE_API_KEY', '')[:10]}...") + print("-" * 60) + + results = [] + results.append(("SYNC", test_sync(messages))) + results.append(("ASYNC", asyncio.run(test_async(messages)))) + results.append(("STREAM", test_stream(messages))) + results.append(("WEBSOCKET", test_websocket(messages))) + + print("\n" + "=" * 60) + print("SUMMARY") + print("=" * 60) + for name, passed in results: + print(f"{name:10s}: {'PASSED' if passed else 'FAILED'}") + + +if __name__ == "__main__": + main() diff --git a/tests/unit/test_cli_main.py b/tests/unit/test_cli_main.py index 6999fc3..d4c839c 100644 --- a/tests/unit/test_cli_main.py +++ b/tests/unit/test_cli_main.py @@ -403,11 +403,25 @@ def mock_files_list(**kwargs): def mock_fine_tunes_list(**kwargs): captured_requests["fine_tunes"] = kwargs - return SimpleNamespace(status_code=200, output={"jobs": []}) + from dashscope.finetune.customize_types import ( + FineTuneList, + FineTuneListOutput, + ) + + result = FineTuneList(status_code=200) + result.output = FineTuneListOutput(jobs=[]) + return result def mock_deployments_list(**kwargs): captured_requests["deployments"] = kwargs - return SimpleNamespace(status_code=200, output={"deployments": []}) + from dashscope.finetune.customize_types import ( + DeploymentList, + DeploymentListOutput, + ) + + result = DeploymentList(status_code=200) + result.output = DeploymentListOutput(deployments=[]) + return result monkeypatch.setattr( "dashscope.cli.files.dashscope.Files.list", diff --git a/tests/unit/test_utf8_encoding.py b/tests/unit/test_utf8_encoding.py new file mode 100644 index 0000000..ebee93c --- /dev/null +++ b/tests/unit/test_utf8_encoding.py @@ -0,0 +1,350 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +"""Unit tests for UTF-8 encoding in HTTP request bodies. + +Verifies that: +1. Content-Type / Accept headers include charset=utf-8 +2. JSON body is serialized with ensure_ascii=False and encoded as UTF-8 +3. Body bytes are smaller than ASCII-escaped equivalent +4. Both sync (HttpRequest) and async (AioHttpRequest) paths behave identically +""" + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from dashscope.api_entities.http_request import HttpRequest +from dashscope.api_entities.aiohttp_request import AioHttpRequest +from dashscope.common.constants import HTTPMethod + + +NON_ASCII_PAYLOAD = { + "model": "qwen-turbo", + "input": { + "messages": [ + {"role": "user", "content": "你好世界"}, + {"role": "user", "content": "Оцени идею"}, + {"role": "user", "content": "こんにちは"}, + {"role": "user", "content": "emoji: 🎉🚀💡"}, + ], + }, + "parameters": {"result_format": "message"}, +} + +EXPECTED_BODY = json.dumps(NON_ASCII_PAYLOAD, ensure_ascii=False).encode( + "utf-8", +) + + +def _make_mock_data(payload): + mock_data = MagicMock() + mock_data.get_http_payload.return_value = (False, None, payload) + mock_data.get_aiohttp_payload.return_value = (False, payload) + mock_data.parameters = {} + return mock_data + + +def _make_mock_sync_session(): + mock_session = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {"Content-Type": "application/json"} + mock_response.iter_content.return_value = [b'{"output":{"text":"ok"}}'] + mock_session.post.return_value = mock_response + return mock_session + + +class _MockAioResponse: + status = 200 + content_type = "application/json" + + async def json(self): + return {"output": {"text": "ok"}, "request_id": "test-123"} + + async def read(self): + return b'{"output":{"text":"ok"},"request_id":"test-123"}' + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + +class _MockAioSession: + def __init__(self): + self.captured = {} + + # pylint: disable=unused-argument + async def request( + self, + method, + url, + data=None, + headers=None, + timeout=None, + ): + self.captured["data"] = data + self.captured["headers"] = headers + return _MockAioResponse() + + async def close(self): + pass + + +# ---- Header tests ---- + + +class TestHttpRequestUtf8Headers: + def test_content_type_has_charset(self): + req = HttpRequest( + url="https://example.com/api/v1/test", + api_key="test-key", + http_method=HTTPMethod.POST, + stream=False, + ) + assert req.headers["Content-Type"] == "application/json; charset=utf-8" + + def test_accept_has_charset(self): + req = HttpRequest( + url="https://example.com/api/v1/test", + api_key="test-key", + http_method=HTTPMethod.POST, + stream=False, + ) + assert req.headers["Accept"] == "application/json; charset=utf-8" + + def test_stream_overrides_accept(self): + req = HttpRequest( + url="https://example.com/api/v1/test", + api_key="test-key", + http_method=HTTPMethod.POST, + stream=True, + ) + assert "text/event-stream" in req.headers["Accept"] + + def test_get_has_no_content_type(self): + req = HttpRequest( + url="https://example.com/api/v1/test", + api_key="test-key", + http_method=HTTPMethod.GET, + stream=False, + ) + assert "Content-Type" not in req.headers + + +class TestAioHttpRequestUtf8Headers: + def test_content_type_has_charset(self): + req = AioHttpRequest( + url="https://example.com/api/v1/test", + api_key="test-key", + http_method=HTTPMethod.POST, + stream=False, + ) + assert req.headers["Content-Type"] == "application/json; charset=utf-8" + + def test_accept_has_charset(self): + req = AioHttpRequest( + url="https://example.com/api/v1/test", + api_key="test-key", + http_method=HTTPMethod.POST, + stream=False, + ) + assert req.headers["Accept"] == "application/json; charset=utf-8" + + +# ---- Sync body tests ---- + + +class TestHttpRequestUtf8Body: + # pylint: disable=protected-access + + @patch("dashscope.api_entities.http_request.requests.Session") + def test_body_is_utf8_bytes(self, mock_session_cls): + mock_session = _make_mock_sync_session() + mock_session_cls.return_value = mock_session + + req = HttpRequest( + url="https://example.com/api/v1/test", + api_key="test-key", + http_method=HTTPMethod.POST, + stream=False, + ) + req.data = _make_mock_data(NON_ASCII_PAYLOAD) + + try: + list(req._handle_request()) + except Exception: + pass + + mock_session.post.assert_called_once() + sent_body = mock_session.post.call_args.kwargs.get("data") + + assert isinstance(sent_body, bytes), "Body must be bytes, not str" + assert sent_body == EXPECTED_BODY + + @patch("dashscope.api_entities.http_request.requests.Session") + def test_no_unicode_escapes_in_body(self, mock_session_cls): + mock_session = _make_mock_sync_session() + mock_session_cls.return_value = mock_session + + req = HttpRequest( + url="https://example.com/api/v1/test", + api_key="test-key", + http_method=HTTPMethod.POST, + stream=False, + ) + req.data = _make_mock_data(NON_ASCII_PAYLOAD) + + try: + list(req._handle_request()) + except Exception: + pass + + sent_body = mock_session.post.call_args.kwargs.get("data") + body_str = sent_body.decode("utf-8") + + assert "\\u" not in body_str, "Body must not contain \\uXXXX escapes" + assert "你好世界" in body_str + assert "Оцени идею" in body_str + assert "こんにちは" in body_str + + @patch("dashscope.api_entities.http_request.requests.Session") + def test_body_is_smaller_than_ascii_escaped(self, mock_session_cls): + mock_session = _make_mock_sync_session() + mock_session_cls.return_value = mock_session + + req = HttpRequest( + url="https://example.com/api/v1/test", + api_key="test-key", + http_method=HTTPMethod.POST, + stream=False, + ) + req.data = _make_mock_data(NON_ASCII_PAYLOAD) + + try: + list(req._handle_request()) + except Exception: + pass + + sent_body = mock_session.post.call_args.kwargs.get("data") + ascii_escaped_size = len( + json.dumps(NON_ASCII_PAYLOAD, ensure_ascii=True).encode("utf-8"), + ) + assert len(sent_body) < ascii_escaped_size + + +# ---- Async body tests ---- + + +@pytest.mark.asyncio +class TestAioHttpRequestUtf8Body: + # pylint: disable=protected-access + + async def test_body_is_utf8_bytes(self): + session = _MockAioSession() + req = AioHttpRequest( + url="https://example.com/api/v1/test", + api_key="test-key", + http_method=HTTPMethod.POST, + stream=False, + session=session, + ) + req.data = _make_mock_data(NON_ASCII_PAYLOAD) + + async for _ in req._handle_request(): + pass + + sent_body = session.captured.get("data") + assert isinstance(sent_body, bytes), "Body must be bytes, not str" + assert sent_body == EXPECTED_BODY + + async def test_no_unicode_escapes_in_body(self): + session = _MockAioSession() + req = AioHttpRequest( + url="https://example.com/api/v1/test", + api_key="test-key", + http_method=HTTPMethod.POST, + stream=False, + session=session, + ) + req.data = _make_mock_data(NON_ASCII_PAYLOAD) + + async for _ in req._handle_request(): + pass + + sent_body = session.captured.get("data") + body_str = sent_body.decode("utf-8") + + assert "\\u" not in body_str, "Body must not contain \\uXXXX escapes" + assert "你好世界" in body_str + assert "Оцени идею" in body_str + + async def test_body_is_smaller_than_ascii_escaped(self): + session = _MockAioSession() + req = AioHttpRequest( + url="https://example.com/api/v1/test", + api_key="test-key", + http_method=HTTPMethod.POST, + stream=False, + session=session, + ) + req.data = _make_mock_data(NON_ASCII_PAYLOAD) + + async for _ in req._handle_request(): + pass + + sent_body = session.captured.get("data") + ascii_escaped_size = len( + json.dumps(NON_ASCII_PAYLOAD, ensure_ascii=True).encode("utf-8"), + ) + assert len(sent_body) < ascii_escaped_size + + +# ---- WebSocket tests ---- + + +class TestWebSocketRequestUtf8: + # pylint: disable=protected-access + + def test_build_up_message_no_unicode_escapes(self): + from dashscope.api_entities.websocket_request import WebSocketRequest + + req = WebSocketRequest( + url="wss://example.com/api-ws/v1/test", + api_key="test-key", + stream=False, + ) + headers = {"task_id": "test-123", "action": "start"} + payload = { + "input": {"messages": [{"role": "user", "content": "你好世界"}]}, + } + + message = req._build_up_message(headers, payload) + + assert isinstance(message, str), "Message must be str" + assert "\\u" not in message, "Message must not contain \\uXXXX escapes" + assert "你好世界" in message + + def test_build_up_message_smaller_than_ascii_escaped(self): + from dashscope.api_entities.websocket_request import WebSocketRequest + + req = WebSocketRequest( + url="wss://example.com/api-ws/v1/test", + api_key="test-key", + stream=False, + ) + headers = {"task_id": "test-123", "action": "start"} + payload = NON_ASCII_PAYLOAD + + message = req._build_up_message(headers, payload) + message_bytes = message.encode("utf-8") + + ascii_escaped = json.dumps( + {"header": headers, "payload": payload}, + ensure_ascii=True, + ).encode("utf-8") + + assert len(message_bytes) < len(ascii_escaped)