anthropics / anthropics/anthropic-sdk-python

`BetaCitationPageLocation` contains `file_id` that cannot be roundtripped as a request

Aperta
#1,450 2 commenti 0 reazioni 0 assegnatari Vedi su GitHub
Lingua principale
Python
Stelle
3.9k
Fork
853
Merge medio
1g 18h
PR unite (30g)
11

Descrizione

**SDK version:** `0.96.0`
**Python version:** 3.x

### Description

When an assistant message contains citations sourced from a URL-based document (a PDF provided via `"source": {"type": "url", ...}`), the API returns `BetaCitationPageLocation` objects that include `file_id: null`. The corresponding param type used to build the *next* request — `BetaCitationPageLocationParam` — has no `file_id` field at all. This means that using `model_dump(mode="json")` to echo the assistant message back in a multi-turn conversation (the standard pattern) causes the API to reject the subsequent request.

This breaks the **roundtrip property**: a response from the API should be usable as-is in the next request without requiring manual transformation.

---

### Steps to reproduce

```python
import anthropic

client = anthropic.Anthropic()

# Turn 1: send a URL-sourced PDF with citations enabled
turn_1_response = client.beta.messages.create(
model="claude-opus-4-5",
max_tokens=1024,
betas=["citations-2023-11-01"],
messages=[
{
"role": "user",
"content": [
{
"type": "document",
"source": {
"type": "url",
"url": "https://www.example.com/sample.pdf", # any publicly accessible PDF
},
"citations": {"enabled": True},
},
{"type": "text", "text": "Summarise page 1."},
],
}
],
)

# Inspect: file_id is null on every page_location citation
for block in turn_1_response.content:
if hasattr(block, "citations") and block.citations:
for citation in block.citations:
if citation.type == "page_location":
print(citation.model_dump(mode="json"))
# Output:
# {
# 'type': 'page_location',
# 'cited_text': '...',
# 'document_index': 0,
# 'document_title': '...',
# 'start_page_number': 1,
# 'end_page_number': 2,
# 'file_id': None, # <-- present in response, not accepted in request
# }

# Turn 2: echo the assistant message back and ask a follow-up — standard multi-turn pattern
turn_2_response = client.beta.messages.create(
model="claude-opus-4-5",
max_tokens=1024,
betas=["citations-2023-11-01"],
messages=[
# ... original user turn ...
{
"role": "assistant",
# model_dump is the natural way to convert a response back into a message param
"content": [block.model_dump(mode="json") for block in turn_1_response.content],
},
{
"role": "user",
"content": "What is the main conclusion?",
},
],
)
# ❌ Raises BadRequestError: file_id is not a valid field in BetaCitationPageLocationParam
```

---

### The type mismatch

**Response type** (`BetaCitationPageLocation`) — includes `file_id`:

```python
class BetaCitationPageLocation(BaseModel):
cited_text: str
document_index: int
document_title: Optional[str] = None
end_page_number: int
file_id: Optional[str] = None # <-- present
start_page_number: int
type: Literal["page_location"]
```

**Request param type** (`BetaCitationPageLocationParam`) — no `file_id`:

```python
class BetaCitationPageLocationParam(TypedDict, total=False):
cited_text: Required[str]
document_index: Required[int]
document_title: Required[Optional[str]]
end_page_number: Required[int]
start_page_number: Required[int]
type: Required[Literal["page_location"]]
# <-- no file_id
```

---

### Why the SDK does not save you automatically

One might expect the transform pipeline to strip `None` values before the HTTP request is sent. It does not. `is_given(None)` returns `True` because the SDK intentionally distinguishes between *"the caller passed `None`"* and *"the caller did not pass this field at all"* (via the `NotGiven` sentinel). This is correct and by design.

The second reason the field is not dropped is that `file_id` is unknown to `_transform_typeddict` when the citation is being processed against `BetaCitationPageLocationParam`. Because the field has no type annotation in the param type, it falls through to the unknown-field branch and is forwarded verbatim:

```python
# anthropic/_utils/_transform.py
type_ = annotations.get(key) # None — file_id is not in BetaCitationPageLocationParam
if type_ is None:
result[key] = value # kept as-is, even when value is None
```

This was verified with a direct call:

```python
from anthropic._utils._transform import _transform_typeddict
import anthropic.types.beta as b

citation = {
"type": "page_location",
"cited_text": "some text",
"document_index": 0,
"document_title": "My Doc",
"start_page_number": 1,
"end_page_number": 2,
"file_id": None,
}

print(_transform_typeddict(citation, b.BetaCitationPageLocationParam))
# {'type': 'page_location', 'cited_text': 'some text', 'document_index': 0,
# 'document_title': 'My Doc', 'start_page_number': 1, 'end_page_number': 2,
# 'file_id': None} <-- file_id survives
```

---

### Expected behaviour

Either:

- **Option A (preferred):** The API should not emit `file_id` in the response when the document was provided via URL. A `null` value for a field that has no meaning in this context and cannot be sent back is actively harmful.
- **Option B:** `BetaCitationPageLocationParam` should accept `file_id: Optional[str]` and the server should tolerate it being `null`, restoring the roundtrip property.

---

### Workaround

Until this is fixed, `file_id` must be stripped manually from `page_location` citations before building subsequent requests:

```python
def sanitize_content_blocks(content_blocks: list) -> list:
result = []
for block in content_blocks:
if isinstance(block, dict) and block.get("type") == "text" and block.get("citations"):
block = {
**block,
"citations": [
{k: v for k, v in c.items() if k != "file_id"}
if isinstance(c, dict) and c.get("type") == "page_location"
else c
for c in block["citations"]
],
}
result.append(block)
return result

# Usage
"content": sanitize_content_blocks([block.model_dump(mode="json") for block in turn_1_response.content])
```

Guida per i contributori

Apri la guida per i contributori

Valutazione

Questa issue non è ancora stata valutata.

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.