anthropics / anthropics/anthropic-sdk-python

Sonnet 5 strict tool calls double-escape Unicode and introduce control characters into copied text

Ouverte
#1,926 0 commentaires 1 réaction 0 personnes assignées Voir sur GitHub
Langage dominant
Python
Étoiles
3.9k
Forks
853
Merge moyen
1 j 11 h
PR mergées (30 j)
10

Description

When asked to copy two short sentences into a strict tool call using ASCII-only JSON serialization, `claude-sonnet-5` sometimes double-escapes non-ASCII characters or substitutes ASCII control characters. These examples are complete, valid JSON and conform to the tool schema, but their decoded text is wrong.

The reproduction is an invented language-example task. `save_translations` accepts an array of objects containing only `language` and `text`. No application framework or Anthropic SDK is involved; the script calls the Messages API directly with HTTPX.

## Requested text and serialization

- German: `Das Café "Möwe" öffnet um 8 Uhr.`
- French: `Le café "Étoile" coûte 8 €.`

The prompt explicitly says: "The tool arguments must use ASCII-only JSON. Encode non-ASCII characters using JSON Unicode escapes. After JSON parsing, the texts must equal the original sentences exactly."

Expected: one ordinary JSON parse recovers those exact sentences. Asking for JSON escapes does not ask for literal backslash-u text in the decoded values. For example, JSON `"Caf\u00e9"` decodes to `Café`, whereas JSON `"Caf\\u00e9"` decodes to literal `Caf\u00e9`.

## Actual example 1: double-escaped Unicode

Exact raw tool arguments, before parsing:

```json
{"translations": [{"language":"German","text":"Das Caf\\u00e9 \"M\\u00f6we\" \\u00f6ffnet um 8 Uhr."},{"language":"French","text":"Le caf\\u00e9 \"\\u00c9toile\" co\\u00fbte 8 \\u20ac."}]}
```

After `json.loads`, the German text contains literal backslash-u sequences. It is not the requested sentence.

- HTTP request ID: `req_011CevJY6BVfgi1sMCHwHS5K`.
- Message ID: `msg_011CevJY7U915LtUZzZbwGxt`.
- Started at `2026-09-10T19:22:24.640081+00:00`.

## Actual example 2: unexpected control character

Exact raw tool arguments:

```json
{"translations": [{"language":"German","text":"Das Caf\ffffnnet um 8 Uhr."},{"language":"French","text":"Le café..."}]}
```

Here `\f` decodes to U+000C FORM FEED, which does not occur in the requested text. The remaining text is also corrupted. Other captured responses substituted U+0009 TAB.

- HTTP request ID: `req_011CevJYwDm2YjBUW55VfRqj`.
- Message ID: `msg_011CevJYx9aXZ3gcA6M6wcpa`.
- Started at `2026-09-10T19:22:36.188577+00:00`.

Both examples returned HTTP 200, reached the end of the stream, and ended with `stop_reason: tool_use` below the output-token cap. The JSON was parsed once, without normalization or further decoding.

## Small comparison

The request body was identical between arms except for the tool's `strict` boolean. Ten strict-on requests were run first, followed by ten strict-off requests.

| Result | strict: true | strict: false |
|---|---:|---:|
| Exact requested decoded text | 0/10 | 10/10 |
| Literal Unicode escape sequences in decoded text | 4/10 | 0/10 |
| Unexpected ASCII control characters in decoded text | 5/10 | 0/10 |
| Array-type schema violation | 1/10 | 0/10 |

Symptom counts overlap. The array-type violation is separately reported in #1925. The other nine strict-on responses conformed to the schema but had incorrect text. All twenty streams completed with `stop_reason: tool_use`.

The strict-off responses used native Unicode, so they preserved the requested text but did not follow the ASCII-only serialization instruction. This comparison measures decoded-text correctness; it does not claim full instruction compliance in the strict-off arm. The small, sequential batches establish reproduction, not a general error rate or an internal causal mechanism.

## Reproduce

Verified with Python 3.13.6 and HTTPX 0.28.1 on macOS arm64. No Anthropic SDK is used.

```sh
python -m pip install httpx==0.28.1
# Set ANTHROPIC_API_KEY in the environment.
python translations_repro.py --attempts 10 --output strict.json
python translations_repro.py --attempts 10 --no-strict --output non-strict.json
```

The script records raw streamed arguments, parsed text, control-character code points and HTTP request IDs. Each invocation has a ten-call limit and an estimated $1 reservation cap. Reproduction is nondeterministic.

Settings: direct `POST https://api.anthropic.com/v1/messages`, API version `2023-06-01`, `claude-sonnet-5`, `eager_input_streaming: false`, streaming enabled, automatic tool choice, adaptive thinking with summarized display, `max_tokens: 8192`, no automatic retries.

Please investigate why requesting valid Unicode-escaped JSON produces corrupted string values with strict tool use. This report concerns text correctness, distinct from the schema-conformance failure in #1925.

Complete standalone translations_repro.py

```python
"""Reproduce an array-type violation in a completed strict tool call.

Requires Python 3.10+ and httpx. Set ANTHROPIC_API_KEY, then run:
python translations_repro.py --attempts 10 --output translations_evidence.json

Calls the HTTP API directly, without the Anthropic SDK or an agent framework.
Runs up to ten requests and records the raw tool arguments and request IDs.
"""

import argparse
import hashlib
import json
import os
import re
from datetime import datetime, timezone
from pathlib import Path

import httpx

REQUEST = {
"model": "claude-sonnet-5",
"system": "Copy the supplied example sentences exactly into save_translations. Preserve every character, including quotation marks and accents.",
"messages": [{
"role": "user",
"content": 'Save these two example sentences:\nGerman: Das Café "Möwe" öffnet um 8 Uhr.\nFrench: Le café "Étoile" coûte 8 €.\n\nThe tool arguments must use ASCII-only JSON. Encode non-ASCII characters using JSON Unicode escapes. After JSON parsing, the texts must equal the original sentences exactly.',
}],
"tools": [{
"name": "save_translations",
"description": "Save example sentences with their language labels.",
"strict": True,
"eager_input_streaming": False,
"input_schema": {
"type": "object",
"properties": {
"translations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"language": {"type": "string", "enum": ["German", "French"]},
"text": {"type": "string"},
},
"required": ["language", "text"],
"additionalProperties": False,
},
},
},
"required": ["translations"],
"additionalProperties": False,
},
}],
"tool_choice": {"type": "auto"},
"thinking": {"type": "adaptive", "display": "summarized"},
"max_tokens": 8192,
"stream": True,
}

def run_once(client, number):
record = {
"attempt": number,
"started_at": datetime.now(timezone.utc).isoformat(),
"complete_stream": False,
"stop_reason": None,
"usage": {},
"tool_calls": [],
}
blocks = {}
with client.stream("POST", "https://api.anthropic.com/v1/messages", json=REQUEST) as response:
record["http_status"] = response.status_code
record["request_id"] = response.headers.get("request-id")
if response.status_code != 200:
# Do not print request headers, credentials, or arbitrary error bodies.
raise RuntimeError(f"HTTP {response.status_code}; request-id={record['request_id']}")
for line in response.iter_lines():
if not line.startswith("data: "):
continue
event = json.loads(line[6:])
kind = event["type"]
if kind == "message_start":
record["message_id"] = event["message"]["id"]
record["usage"].update(event["message"].get("usage", {}))
elif kind == "content_block_start" and event["content_block"]["type"] == "tool_use":
block = event["content_block"]
blocks[event["index"]] = {
"name": block["name"], "id": block["id"], "raw_arguments": "",
}
elif kind == "content_block_delta" and event["delta"]["type"] == "input_json_delta":
blocks[event["index"]]["raw_arguments"] += event["delta"]["partial_json"]
elif kind == "message_delta":
record["stop_reason"] = event["delta"].get("stop_reason")
record["usage"].update({k: v for k, v in event.get("usage", {}).items() if v is not None})
elif kind == "message_stop":
record["complete_stream"] = True
elif kind == "error":
raise RuntimeError(f"SSE error; request-id={record['request_id']}")
violation = False
for block in blocks.values():
try:
arguments = json.loads(block["raw_arguments"])
block["parsed_arguments"] = arguments
if block["name"] == "save_translations" and isinstance(arguments, dict):
block["translations_python_type"] = type(arguments.get("translations")).__name__
violation |= not isinstance(arguments.get("translations"), list)
except json.JSONDecodeError as error:
block["parse_error"] = str(error)
record["tool_calls"].append(block)
record["schema_violation"] = (
record["complete_stream"] and record["stop_reason"] == "tool_use" and violation
)
values = {}
for block in record["tool_calls"]:
payload = block.get("parsed_arguments", {})
items = payload.get("translations") if isinstance(payload, dict) else None
if isinstance(items, list):
for item in items:
if isinstance(item, dict) and isinstance(item.get("text"), str):
values[item.get("language")] = item["text"]
record["exact_text"] = values == {
"German": 'Das Café "Möwe" öffnet um 8 Uhr.',
"French": 'Le café "Étoile" coûte 8 €.',
}
record["literal_unicode"] = [
match for value in values.values()
for match in re.findall(r"\\u[0-9a-fA-F]{4}", value)
]
record["control_characters"] = sorted({
f"U+{ord(char):04X}" for value in values.values() for char in value
if ord(char) < 32 or 127 <= ord(char) <= 159
})
return record

def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--attempts", type=int, default=10, choices=range(1, 11), metavar="1..10")
parser.add_argument("--output", type=Path, default=Path("translations_evidence.json"))
parser.add_argument("--no-strict", action="store_true", help="Run the same request with strict mode disabled")
args = parser.parse_args()
REQUEST["tools"][0]["strict"] = not args.no_strict
key = os.environ["ANTHROPIC_API_KEY"]
evidence = {
"endpoint": "https://api.anthropic.com/v1/messages",
"anthropic_version": "2023-06-01",
"httpx_version": httpx.__version__,
"request": REQUEST,
"request_sha256": hashlib.sha256(json.dumps(REQUEST, sort_keys=True, ensure_ascii=False).encode()).hexdigest(),
"attempts": [],
"estimated_cost_usd": 0,
}
# Conservative reservation using request bytes and the full output cap.
# Direct Sonnet 5 prices at verification: $2/M input, $10/M output.
reserve = ((len(json.dumps(REQUEST, ensure_ascii=False).encode()) + 4000) * 2 + 8192 * 10) / 1e6 * 1.25
with httpx.Client(headers={"x-api-key": key, "anthropic-version": "2023-06-01"}, timeout=180) as client:
for number in range(1, args.attempts + 1):
if evidence["estimated_cost_usd"] + reserve > 1:
print("Stopped before exceeding the $1 reservation cap.")
break
record = run_once(client, number)
evidence["attempts"].append(record)
usage = record["usage"]
cost = (usage.get("input_tokens", 0) * 2 + usage.get("output_tokens", 0) * 10) / 1e6
evidence["estimated_cost_usd"] += cost if record["complete_stream"] else reserve
args.output.write_text(json.dumps(evidence, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps({k: record.get(k) for k in ("attempt", "request_id", "message_id", "stop_reason", "schema_violation", "exact_text", "literal_unicode", "control_characters")}), flush=True)
if record["schema_violation"]:
print("REPRODUCED: completed tool call violated the translations array constraint.")
print(f"Estimated cost: ${evidence['estimated_cost_usd']:.5f}")

if __name__ == "__main__":
main()
```

Guide de contribution

Ouvrir le guide de contribution

Piste de recherche

Start with the standalone translations_repro.py reproduction and run the documented strict and --no-strict commands against the Messages API, reviewing raw streamed tool arguments and the parsed values. Compare the strict-on results with the expected German and French sentences; done means strict tool calls preserve the exact decoded text without control characters or literal Unicode escape sequences.

Rédigé par le modèle d'indexation à partir du texte de l'issue.

Évaluation

Stack technique
python
Domaine
api
Type d'issue
Bug
Difficulté
4/5
Temps estimé
3-5 jours
Activité
Active
Clarté
Plutôt claire
Accessibilité débutants
45/100

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.