提交

Docs: rewrite parts/messages section around messages= API

- python-api.md: 'Parts and stream events' section reworks the example
  code around response.messages[].parts and drops the old parts=
  parameter docs. New 'Prompting with messages' section shows the
  user/assistant/system/tool_message helpers, parallel tool calls as
  one assistant message, and Attachment-as-positional-arg.
- advanced-model-plugins.md: plugin author guide walks prompt.messages
  instead of the old flat prompt.parts + legacy fields. provider_metadata
  example no longer passes role= to TextPart; storage reference updated
  to message_parts table.
这个提交包含在:
Simon Willison
2026-04-12 21:50:46 -07:00
父节点 d6e810226a
当前提交 82b1f4c17a
修改 2 个文件,包含 64 行新增97 行删除
+22 -30
查看文件
@@ -246,30 +246,20 @@ As you can see, it uses `attachment.url` if that is available and otherwise fall
### Attachments from previous conversations
Models that implement the ability to continue a conversation can reconstruct the previous message JSON using the `response.attachments` attribute.
Here's how the OpenAI plugin does that:
The canonical source of structured input for a turn is `prompt.messages` — a list of `llm.Message` objects, each with a `role` and a list of parts (`TextPart`, `AttachmentPart`, `ToolCallPart`, `ToolResultPart`, etc). A plugin's `build_messages` (or equivalent) should iterate these and translate each part to whatever the underlying API expects:
```python
for prev_response in conversation.responses:
if prev_response.attachments:
attachment_message = []
if prev_response.prompt.prompt:
attachment_message.append(
{"type": "text", "text": prev_response.prompt.prompt}
)
for attachment in prev_response.attachments:
attachment_message.append(_attachment(attachment))
messages.append({"role": "user", "content": attachment_message})
else:
messages.append(
{"role": "user", "content": prev_response.prompt.prompt}
)
messages.append({"role": "assistant", "content": prev_response.text_or_raise()})
for message in prompt.messages:
for part in message.parts:
if isinstance(part, TextPart):
...
elif isinstance(part, AttachmentPart):
...
elif isinstance(part, ToolCallPart):
...
```
The `response.text_or_raise()` method used there will return the text from the response or raise a `ValueError` exception if the response is an `AsyncResponse` instance that has not yet been fully resolved.
This is a slightly weird hack to work around the common need to share logic for building up the `messages` list across both sync and async models.
For conversation history, walk `conversation.responses` and for each `prev_response`, consume `prev_response.prompt.messages` (the input messages from that turn) plus `prev_response.messages` (the assistant response). The `response.text_or_raise()` / `response.tool_calls_or_raise()` accessors are still available as a shortcut for flat text-plus-tool-calls assistant turns and are safe to use from both sync and async code paths.
(advanced-model-plugins-usage)=
@@ -328,7 +318,6 @@ providers don't collide:
```python
TextPart(
role="assistant",
text="...",
provider_metadata={"anthropic": {"citations": [...]}},
)
@@ -397,13 +386,16 @@ key is missing, fall through as if nothing was stored — an older transcript
may predate this support.
```python
for part in prompt.parts:
if isinstance(part, ReasoningPart):
block = {"type": "thinking", "thinking": part.text}
sig = (part.provider_metadata or {}).get("anthropic", {}).get("signature")
if sig:
block["signature"] = sig
content.append(block)
for message in prompt.messages:
for part in message.parts:
if isinstance(part, ReasoningPart):
block = {"type": "thinking", "thinking": part.text}
sig = (part.provider_metadata or {}).get("anthropic", {}).get(
"signature"
)
if sig:
block["signature"] = sig
content.append(block)
```
### Contract
@@ -412,10 +404,10 @@ for part in prompt.parts:
entries, and do not rely on internal structure of your own entry beyond
what your API documents — providers change these payloads.
- Serialize cleanly. Anything you put in `provider_metadata` must be JSON
round-trippable; it's persisted in the `parts` table as JSON.
round-trippable; it's persisted in the `message_parts` table as JSON.
- Don't store secrets here. API keys, user PII, and anything else that
shouldn't be logged don't belong in `provider_metadata` — it lands in
the user's `logs.db` like any other part data.
the user's `logs.db` like any other message data.
(tutorial-model-plugin-raise-errors)=
+42 -67
查看文件
@@ -528,29 +528,26 @@ If a response has been evaluated, `response.text()` will continue to return the
(python-api-parts)=
### Parts and stream events
### Messages, parts, and stream events
After a response completes, `response.parts` provides a structured list of everything the model produced — text, reasoning, tool calls, and tool results — as typed Python objects.
LLM models exchange structured **messages**, each with a role (`user`, `assistant`, `system`, `tool`) and a list of **parts** (text, reasoning, tool calls, tool results, attachments). After a response completes, `response.messages` is the canonical structured view of what the model produced, and the `messages=` parameter on `model.prompt()` is the canonical way to supply explicit conversation history.
```python
response = model.prompt("Five names for a pet pelican")
response.text() # Force completion
for part in response.parts:
print(type(part).__name__, part.to_dict())
```
Output:
```python
TextPart {'role': 'assistant', 'type': 'text', 'text': '1. Captain...'}
for message in response.messages:
for part in message.parts:
print(type(part).__name__, part.to_dict())
```
For models that support extended thinking (such as Claude with `thinking=True`), reasoning appears as a separate part:
For models that support extended thinking (such as Claude with `thinking=True`), reasoning appears as a separate part within the assistant message:
```python
model = llm.get_model("claude-sonnet-4.5")
response = model.prompt("Count the Rs in strawberry", thinking=True)
response.text()
for part in response.parts:
print(type(part).__name__, repr(part.text)[:80])
for part in response.messages[0].parts:
print(type(part).__name__, repr(getattr(part, "text", ""))[:80])
```
Output:
```python
@@ -561,43 +558,22 @@ TextPart 'There are 3 Rs in "strawberry".'
Some models (like OpenAI's GPT-5 series) use reasoning internally but don't expose the text. In that case you'll see a redacted reasoning part with a token count:
```python
model = llm.get_model("gpt-5.4-mini")
response = model.prompt("What is 13 * 17?", reasoning_effort="high")
response.text()
for part in response.parts:
for part in response.messages[0].parts:
if hasattr(part, 'redacted') and part.redacted:
print(f"ReasoningPart (redacted, {part.token_count} tokens)")
else:
print(type(part).__name__, repr(part.text)[:60])
```
Tool calls and their results also appear as parts:
```python
def get_weather(city: str) -> str:
"""Get weather for a city."""
return f"Sunny, 72°F in {city}"
model = llm.get_model("gpt-4.1-mini")
response = model.prompt("Weather in Paris?", tools=[get_weather])
response.text()
for part in response.parts:
print(type(part).__name__, part.to_dict())
```
Output:
```python
ToolCallPart {'role': 'assistant', 'type': 'tool_call', 'name': 'get_weather', 'arguments': {'city': 'Paris'}, 'tool_call_id': 'call_...'}
```
Tool calls and their results also appear as parts inside the assistant message.
The available part types are:
- **`llm.TextPart`** — text content from the model
- **`llm.TextPart`** — text content
- **`llm.ReasoningPart`** — reasoning/thinking tokens (may be `redacted=True` with only a `token_count`)
- **`llm.ToolCallPart`** — a tool call request with `name`, `arguments`, `tool_call_id`
- **`llm.ToolResultPart`** — a tool result with `output`, `tool_call_id`
- **`llm.AttachmentPart`** — an inline attachment
All part types have `to_dict()` for JSON serialization and can be restored with `llm.Part.from_dict(d)`.
Parts don't carry a role — the role lives on the enclosing `llm.Message`. All part types have `to_dict()` for JSON serialization and can be restored with `llm.Part.from_dict(d)`. Messages also round-trip via `llm.Message.to_dict()` / `llm.Message.from_dict()`.
(python-api-stream-events)=
@@ -632,58 +608,57 @@ async for event in response.astream_events():
Regular iteration (`for chunk in response`) continues to yield only text strings — reasoning and tool call events are filtered out. This ensures backward compatibility. Use `stream_events()` when you need the full picture.
(python-api-parts-parameter)=
(python-api-messages-parameter)=
#### Prompting with parts
#### Prompting with messages
The `parts=` parameter on `model.prompt()` lets you construct prompts with explicit typed parts instead of just a text string. This is useful for building multi-message prompts or passing structured conversation history:
The `messages=` parameter on `model.prompt()` lets you supply an explicit list of `llm.Message` objects as structured conversation history. The `user`, `assistant`, `system`, and `tool_message` helpers make these pleasant to write:
```python
import llm
from llm.parts import TextPart
from llm import user, assistant, system
model = llm.get_model("gpt-4o-mini")
response = model.prompt(parts=[
TextPart(role="system", text="You are a helpful pirate."),
TextPart(role="user", text="What's the weather like?"),
response = model.prompt(messages=[
system("You are a helpful pirate."),
user("What is the capital of France?"),
assistant("Paris, matey."),
user("And Germany?"),
])
print(response.text())
```
You can combine `parts=` with `prompt=` — the prompt text is appended as a user-role `TextPart`:
The helpers accept strings (wrapped as `TextPart`), `llm.Attachment` instances (wrapped as `AttachmentPart`), existing `Part` objects, or lists/tuples of any of those (flattened one level):
```python
response = model.prompt(
"Now tell me about parrots",
parts=[TextPart(role="system", text="You are a helpful pirate.")],
)
response = model.prompt(messages=[
user("Describe this image.", llm.Attachment(path="cat.jpg")),
])
```
The `system=` and `attachments=` parameters also combine with `parts=`:
Parallel tool calls are one assistant message with multiple `ToolCallPart`s:
```python
from llm.parts import TextPart, AttachmentPart
from llm import user, assistant, tool_message
from llm.parts import ToolCallPart, ToolResultPart
response = model.prompt(
"Describe this image in pirate speak",
parts=[TextPart(role="system", text="You are a pirate.")],
attachments=[llm.Attachment(path="treasure_map.jpg")],
)
messages = [
user("Weather in Paris and Tokyo?"),
assistant(
"I'll check both.",
ToolCallPart(name="get_weather", arguments={"location": "Paris"}, tool_call_id="c1"),
ToolCallPart(name="get_weather", arguments={"location": "Tokyo"}, tool_call_id="c2"),
),
tool_message(
ToolResultPart(name="get_weather", output="sunny", tool_call_id="c1"),
ToolResultPart(name="get_weather", output="rain", tool_call_id="c2"),
),
]
```
The `prompt.parts` property provides a unified view of all input parts, regardless of how they were specified:
Provider adapters translate this structure to whatever the underlying API expects (OpenAI's `tool_calls` array, Anthropic's `tool_use` blocks, Gemini's `functionCall` parts).
```python
response = model.prompt("Hello", system="Be brief")
response.text()
for part in response.prompt.parts:
print(part.to_dict())
```
Output:
```python
{'role': 'system', 'type': 'text', 'text': 'Be brief'}
{'role': 'user', 'type': 'text', 'text': 'Hello'}
```
The simple `model.prompt("hi", system="Be brief.")` form keeps working — it's equivalent to `model.prompt(messages=[system("Be brief."), user("hi")])`.
(python-api-async)=