项目文件夹

文件
Simon Willison a2547d8183 Drop token_count from ReasoningPart, use redacted marker StreamEvent
ReasoningPart.token_count duplicated info already on response.token_details
(reasoning_tokens), and the side-channel `response._reasoning_token_count`
attribute with its set_usage ordering footgun was the wrong shape. Replaced
with a clean StreamEvent.redacted=True marker that plugins yield like any
other event. The framework hoists redacted reasoning Parts to the start of
the assembled message so UIs render them before content, even though the
opaque count typically arrives at the end of the stream.

Also fix parallel tool calls emitted without tool_call_id (e.g. Gemini):
a fresh tool_call_name now always allocates a new index instead of falling
through to the prior tool-call group, so N parallel calls produce N
distinct ToolCallParts instead of one with concatenated names and args.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 13:28:55 -07:00

1301 行
43 KiB
Python

from llm import (
AsyncConversation,
AsyncKeyModel,
AsyncResponse,
Conversation,
EmbeddingModel,
KeyModel,
Prompt,
Response,
StreamEvent,
hookimpl,
)
import llm
from llm.utils import (
dicts_to_table_string,
remove_dict_none_values,
logging_client,
simplify_usage_dict,
)
import click
import datetime
from enum import Enum
import httpx
import openai
import os
from pydantic import create_model, field_validator, Field
from typing import (
Any,
AsyncGenerator,
cast,
Dict,
List,
Iterable,
Iterator,
Optional,
Union,
)
import json
import yaml
@hookimpl
def register_models(register):
# GPT-4o
register(
Chat("gpt-4o", vision=True, supports_schema=True, supports_tools=True),
AsyncChat("gpt-4o", vision=True, supports_schema=True, supports_tools=True),
aliases=("4o",),
)
register(
Chat("chatgpt-4o-latest", vision=True),
AsyncChat("chatgpt-4o-latest", vision=True),
aliases=("chatgpt-4o",),
)
register(
Chat("gpt-4o-mini", vision=True, supports_schema=True, supports_tools=True),
AsyncChat(
"gpt-4o-mini", vision=True, supports_schema=True, supports_tools=True
),
aliases=("4o-mini",),
)
for audio_model_id in (
"gpt-4o-audio-preview",
"gpt-4o-audio-preview-2024-12-17",
"gpt-4o-audio-preview-2024-10-01",
"gpt-4o-mini-audio-preview",
"gpt-4o-mini-audio-preview-2024-12-17",
):
register(
Chat(audio_model_id, audio=True),
AsyncChat(audio_model_id, audio=True),
)
# GPT-4.1
for model_id in ("gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano"):
register(
Chat(model_id, vision=True, supports_schema=True, supports_tools=True),
AsyncChat(model_id, vision=True, supports_schema=True, supports_tools=True),
aliases=(model_id.replace("gpt-", ""),),
)
# 3.5 and 4
register(
Chat("gpt-3.5-turbo"), AsyncChat("gpt-3.5-turbo"), aliases=("3.5", "chatgpt")
)
register(
Chat("gpt-3.5-turbo-16k"),
AsyncChat("gpt-3.5-turbo-16k"),
aliases=("chatgpt-16k", "3.5-16k"),
)
register(Chat("gpt-4"), AsyncChat("gpt-4"), aliases=("4", "gpt4"))
register(Chat("gpt-4-32k"), AsyncChat("gpt-4-32k"), aliases=("4-32k",))
# GPT-4 Turbo models
register(Chat("gpt-4-1106-preview"), AsyncChat("gpt-4-1106-preview"))
register(Chat("gpt-4-0125-preview"), AsyncChat("gpt-4-0125-preview"))
register(Chat("gpt-4-turbo-2024-04-09"), AsyncChat("gpt-4-turbo-2024-04-09"))
register(
Chat("gpt-4-turbo"),
AsyncChat("gpt-4-turbo"),
aliases=("gpt-4-turbo-preview", "4-turbo", "4t"),
)
# GPT-4.5
register(
Chat(
"gpt-4.5-preview-2025-02-27",
vision=True,
supports_schema=True,
supports_tools=True,
),
AsyncChat(
"gpt-4.5-preview-2025-02-27",
vision=True,
supports_schema=True,
supports_tools=True,
),
)
register(
Chat("gpt-4.5-preview", vision=True, supports_schema=True, supports_tools=True),
AsyncChat(
"gpt-4.5-preview", vision=True, supports_schema=True, supports_tools=True
),
aliases=("gpt-4.5",),
)
# o1
for model_id in ("o1", "o1-2024-12-17"):
register(
Chat(
model_id,
vision=True,
can_stream=False,
reasoning=True,
supports_schema=True,
supports_tools=True,
),
AsyncChat(
model_id,
vision=True,
can_stream=False,
reasoning=True,
supports_schema=True,
supports_tools=True,
),
)
register(
Chat("o1-preview", allows_system_prompt=False),
AsyncChat("o1-preview", allows_system_prompt=False),
)
register(
Chat("o1-mini", allows_system_prompt=False),
AsyncChat("o1-mini", allows_system_prompt=False),
)
register(
Chat("o3-mini", reasoning=True, supports_schema=True, supports_tools=True),
AsyncChat("o3-mini", reasoning=True, supports_schema=True, supports_tools=True),
)
register(
Chat(
"o3", vision=True, reasoning=True, supports_schema=True, supports_tools=True
),
AsyncChat(
"o3", vision=True, reasoning=True, supports_schema=True, supports_tools=True
),
)
register(
Chat(
"o4-mini",
vision=True,
reasoning=True,
supports_schema=True,
supports_tools=True,
),
AsyncChat(
"o4-mini",
vision=True,
reasoning=True,
supports_schema=True,
supports_tools=True,
),
)
# GPT-5
for model_id in (
"gpt-5",
"gpt-5-mini",
"gpt-5-nano",
"gpt-5-2025-08-07",
"gpt-5-mini-2025-08-07",
"gpt-5-nano-2025-08-07",
):
register(
Chat(
model_id,
vision=True,
reasoning=True,
verbosity=True,
supports_schema=True,
supports_tools=True,
),
AsyncChat(
model_id,
vision=True,
reasoning=True,
verbosity=True,
supports_schema=True,
supports_tools=True,
),
)
# GPT-5.1
for model_id in (
"gpt-5.1",
"gpt-5.1-chat-latest",
):
register(
Chat(
model_id,
vision=True,
reasoning=True,
verbosity=True,
supports_schema=True,
supports_tools=True,
),
AsyncChat(
model_id,
vision=True,
reasoning=True,
verbosity=True,
supports_schema=True,
supports_tools=True,
),
)
# GPT-5.2
for model_id in ("gpt-5.2", "gpt-5.2-chat-latest"):
register(
Chat(
model_id,
vision=True,
reasoning=True,
verbosity=True,
supports_schema=True,
supports_tools=True,
),
AsyncChat(
model_id,
vision=True,
reasoning=True,
verbosity=True,
supports_schema=True,
supports_tools=True,
),
)
# "gpt-5.2-pro" is Responses API only
# GPT-5.4
for model_id in (
"gpt-5.4",
"gpt-5.4-2026-03-05",
"gpt-5.4-mini",
"gpt-5.4-mini-2026-03-17",
"gpt-5.4-nano",
"gpt-5.4-nano-2026-03-17",
):
register(
Chat(
model_id,
vision=True,
reasoning=True,
verbosity=True,
image_detail_original=True,
supports_schema=True,
supports_tools=True,
),
AsyncChat(
model_id,
vision=True,
reasoning=True,
verbosity=True,
image_detail_original=True,
supports_schema=True,
supports_tools=True,
),
)
# GPT-5.5
for model_id in (
"gpt-5.5",
"gpt-5.5-2026-04-23",
):
register(
Chat(
model_id,
vision=True,
reasoning=True,
verbosity=True,
image_detail_original=True,
supports_schema=True,
supports_tools=True,
),
AsyncChat(
model_id,
vision=True,
reasoning=True,
verbosity=True,
image_detail_original=True,
supports_schema=True,
supports_tools=True,
),
)
# The -instruct completion model
register(
Completion("gpt-3.5-turbo-instruct", default_max_tokens=256),
aliases=("3.5-instruct", "chatgpt-instruct"),
)
# Load extra models
extra_path = llm.user_dir() / "extra-openai-models.yaml"
if not extra_path.exists():
return
with open(extra_path) as f:
extra_models = yaml.safe_load(f)
for extra_model in extra_models:
model_id = extra_model["model_id"]
aliases = extra_model.get("aliases", [])
model_name = extra_model["model_name"]
api_base = extra_model.get("api_base")
api_type = extra_model.get("api_type")
api_version = extra_model.get("api_version")
api_engine = extra_model.get("api_engine")
headers = extra_model.get("headers")
reasoning = extra_model.get("reasoning")
kwargs = {}
if extra_model.get("can_stream") is False:
kwargs["can_stream"] = False
if extra_model.get("supports_schema") is True:
kwargs["supports_schema"] = True
if extra_model.get("supports_tools") is True:
kwargs["supports_tools"] = True
if extra_model.get("vision") is True:
kwargs["vision"] = True
if extra_model.get("audio") is True:
kwargs["audio"] = True
if extra_model.get("completion"):
klass = Completion
async_klass = None
else:
klass = Chat
async_klass = AsyncChat
model_kwargs = dict(
model_id=model_id,
model_name=model_name,
api_base=api_base,
api_type=api_type,
api_version=api_version,
api_engine=api_engine,
headers=headers,
reasoning=reasoning,
**kwargs,
)
chat_model = klass(**model_kwargs)
async_model = async_klass(**model_kwargs) if async_klass else None
if api_base:
chat_model.needs_key = None
if async_model:
async_model.needs_key = None
if extra_model.get("api_key_name"):
chat_model.needs_key = extra_model["api_key_name"]
if async_model:
async_model.needs_key = extra_model["api_key_name"]
register(
chat_model,
async_model,
aliases=aliases,
)
@hookimpl
def register_embedding_models(register):
register(
OpenAIEmbeddingModel("text-embedding-ada-002", "text-embedding-ada-002"),
aliases=(
"ada",
"ada-002",
),
)
register(
OpenAIEmbeddingModel("text-embedding-3-small", "text-embedding-3-small"),
aliases=("3-small",),
)
register(
OpenAIEmbeddingModel("text-embedding-3-large", "text-embedding-3-large"),
aliases=("3-large",),
)
# With varying dimensions
register(
OpenAIEmbeddingModel(
"text-embedding-3-small-512", "text-embedding-3-small", 512
),
aliases=("3-small-512",),
)
register(
OpenAIEmbeddingModel(
"text-embedding-3-large-256", "text-embedding-3-large", 256
),
aliases=("3-large-256",),
)
register(
OpenAIEmbeddingModel(
"text-embedding-3-large-1024", "text-embedding-3-large", 1024
),
aliases=("3-large-1024",),
)
class OpenAIEmbeddingModel(EmbeddingModel):
needs_key = "openai"
key_env_var = "OPENAI_API_KEY"
batch_size = 100
def __init__(self, model_id, openai_model_id, dimensions=None):
self.model_id = model_id
self.openai_model_id = openai_model_id
self.dimensions = dimensions
def embed_batch(self, items: Iterable[Union[str, bytes]]) -> Iterator[List[float]]:
kwargs = {
"input": items,
"model": self.openai_model_id,
}
if self.dimensions:
kwargs["dimensions"] = self.dimensions
client = openai.OpenAI(api_key=self.get_key())
results = client.embeddings.create(**kwargs).data
return ([float(r) for r in result.embedding] for result in results)
@hookimpl
def register_commands(cli):
@cli.group(name="openai")
def openai_():
"Commands for working directly with the OpenAI API"
@openai_.command()
@click.option("json_", "--json", is_flag=True, help="Output as JSON")
@click.option("--key", help="OpenAI API key")
def models(json_, key):
"List models available to you from the OpenAI API"
from llm import get_key
api_key = get_key(key, "openai", "OPENAI_API_KEY")
response = httpx.get(
"https://api.openai.com/v1/models",
headers={"Authorization": f"Bearer {api_key}"},
)
if response.status_code != 200:
raise click.ClickException(
f"Error {response.status_code} from OpenAI API: {response.text}"
)
models = response.json()["data"]
if json_:
click.echo(json.dumps(models, indent=4))
else:
to_print = []
for model in models:
# Print id, owned_by, root, created as ISO 8601
created_str = datetime.datetime.fromtimestamp(
model["created"], datetime.timezone.utc
).isoformat()
to_print.append(
{
"id": model["id"],
"owned_by": model["owned_by"],
"created": created_str,
}
)
done = dicts_to_table_string("id owned_by created".split(), to_print)
print("\n".join(done))
class SharedOptions(llm.Options):
temperature: Optional[float] = Field(
description=(
"What sampling temperature to use, between 0 and 2. Higher values like "
"0.8 will make the output more random, while lower values like 0.2 will "
"make it more focused and deterministic."
),
ge=0,
le=2,
default=None,
)
max_tokens: Optional[int] = Field(
description="Maximum number of tokens to generate.", default=None
)
top_p: Optional[float] = Field(
description=(
"An alternative to sampling with temperature, called nucleus sampling, "
"where the model considers the results of the tokens with top_p "
"probability mass. So 0.1 means only the tokens comprising the top "
"10% probability mass are considered. Recommended to use top_p or "
"temperature but not both."
),
ge=0,
le=1,
default=None,
)
frequency_penalty: Optional[float] = Field(
description=(
"Number between -2.0 and 2.0. Positive values penalize new tokens based "
"on their existing frequency in the text so far, decreasing the model's "
"likelihood to repeat the same line verbatim."
),
ge=-2,
le=2,
default=None,
)
presence_penalty: Optional[float] = Field(
description=(
"Number between -2.0 and 2.0. Positive values penalize new tokens based "
"on whether they appear in the text so far, increasing the model's "
"likelihood to talk about new topics."
),
ge=-2,
le=2,
default=None,
)
stop: Optional[str] = Field(
description=("A string where the API will stop generating further tokens."),
default=None,
)
logit_bias: Optional[Union[dict, str]] = Field(
description=(
"Modify the likelihood of specified tokens appearing in the completion. "
'Pass a JSON string like \'{"1712":-100, "892":-100, "1489":-100}\''
),
default=None,
)
seed: Optional[int] = Field(
description="Integer seed to attempt to sample deterministically",
default=None,
)
@field_validator("logit_bias")
def validate_logit_bias(cls, logit_bias):
if logit_bias is None:
return None
if isinstance(logit_bias, str):
try:
logit_bias = json.loads(logit_bias)
except json.JSONDecodeError:
raise ValueError("Invalid JSON in logit_bias string")
validated_logit_bias = {}
for key, value in logit_bias.items():
try:
int_key = int(key)
int_value = int(value)
if -100 <= int_value <= 100:
validated_logit_bias[int_key] = int_value
else:
raise ValueError("Value must be between -100 and 100")
except ValueError:
raise ValueError("Invalid key-value pair in logit_bias dictionary")
return validated_logit_bias
class ReasoningEffortEnum(str, Enum):
none = "none"
minimal = "minimal"
low = "low"
medium = "medium"
high = "high"
xhigh = "xhigh"
class VerbosityEnum(str, Enum):
low = "low"
medium = "medium"
high = "high"
class ImageDetailEnum(str, Enum):
low = "low"
high = "high"
auto = "auto"
class ImageDetailWithOriginalEnum(str, Enum):
low = "low"
high = "high"
original = "original"
auto = "auto"
def enum_values_sentence(enum_class):
values = [item.value for item in enum_class]
if len(values) == 1:
return values[0]
return "{}, and {}".format(", ".join(values[:-1]), values[-1])
def build_options_class(
*, reasoning=False, verbosity=False, image_detail_original=False
):
fields = {
"json_object": (
Optional[bool],
Field(
description="Output a valid JSON object {...}. Prompt must mention JSON.",
default=None,
),
)
}
image_detail_enum = (
ImageDetailWithOriginalEnum if image_detail_original else ImageDetailEnum
)
image_detail_values = enum_values_sentence(image_detail_enum)
fields["image_detail"] = (
Optional[image_detail_enum],
Field(
description=(
"Controls the detail level for image attachments. Supported values are "
f"{image_detail_values}."
),
default=None,
),
)
if reasoning:
fields["reasoning_effort"] = (
Optional[ReasoningEffortEnum],
Field(
description=(
"Constraints effort on reasoning for reasoning models. Currently "
"supported values are low, medium, and high. Reducing reasoning "
"effort can result in faster responses and fewer tokens used on "
"reasoning in a response."
),
default=None,
),
)
if verbosity:
fields["verbosity"] = (
Optional[VerbosityEnum],
Field(
description=(
"Controls how verbose the model's response should be. Supported "
"values are low, medium, and high."
),
default=None,
),
)
return create_model("Options", __base__=SharedOptions, **fields)
def _attachment(attachment, image_detail=None):
url = attachment.url
base64_content = ""
if not url or attachment.resolve_type().startswith("audio/"):
base64_content = attachment.base64_content()
url = f"data:{attachment.resolve_type()};base64,{base64_content}"
if attachment.resolve_type() == "application/pdf":
if not base64_content:
base64_content = attachment.base64_content()
return {
"type": "file",
"file": {
"filename": f"{attachment.id()}.pdf",
"file_data": f"data:application/pdf;base64,{base64_content}",
},
}
if attachment.resolve_type().startswith("image/"):
image_url = {"url": url}
if image_detail:
image_url["detail"] = image_detail
return {"type": "image_url", "image_url": image_url}
else:
format_ = "wav" if attachment.resolve_type() == "audio/wav" else "mp3"
return {
"type": "input_audio",
"input_audio": {
"data": base64_content,
"format": format_,
},
}
class _Shared:
def __init__(
self,
model_id,
key=None,
model_name=None,
api_base=None,
api_type=None,
api_version=None,
api_engine=None,
headers=None,
can_stream=True,
vision=False,
audio=False,
reasoning=False,
verbosity=False,
image_detail_original=False,
supports_schema=False,
supports_tools=False,
allows_system_prompt=True,
):
self.model_id = model_id
self.key = key
self.supports_schema = supports_schema
self.supports_tools = supports_tools
self.model_name = model_name
self.api_base = api_base
self.api_type = api_type
self.api_version = api_version
self.api_engine = api_engine
self.headers = headers
self.can_stream = can_stream
self.vision = vision
self.allows_system_prompt = allows_system_prompt
self.attachment_types = set()
if reasoning or verbosity or image_detail_original:
self.Options = build_options_class(
reasoning=reasoning,
verbosity=verbosity,
image_detail_original=image_detail_original,
)
if vision:
self.attachment_types.update(
{
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
"application/pdf",
}
)
if audio:
self.attachment_types.update(
{
"audio/wav",
"audio/mpeg",
}
)
def __str__(self) -> str:
return "OpenAI Chat: {}".format(self.model_id)
def _append_llm_message(self, out, message, current_system, image_detail=None):
"""Translate one llm.Message into one (or more) OpenAI message
dicts and append them to ``out``.
Returns the (possibly updated) current_system value so the caller
can avoid re-emitting an unchanged system prompt.
"""
from llm.parts import (
AttachmentPart,
TextPart,
ToolCallPart,
ToolResultPart,
)
text_bits = []
attachment_items = []
tool_calls = []
tool_results = []
for part in message.parts:
if isinstance(part, TextPart):
text_bits.append(part.text)
elif isinstance(part, AttachmentPart) and part.attachment:
attachment_items.append(
_attachment(part.attachment, image_detail=image_detail)
)
elif isinstance(part, ToolCallPart):
tool_calls.append(
{
"type": "function",
"id": part.tool_call_id,
"function": {
"name": part.name,
"arguments": json.dumps(part.arguments),
},
}
)
elif isinstance(part, ToolResultPart):
tool_results.append(
{
"role": "tool",
"tool_call_id": part.tool_call_id,
"content": part.output,
}
)
# Role "tool" emits one OpenAI "tool" message per ToolResultPart.
if message.role == "tool":
out.extend(tool_results)
return current_system
# System dedup: skip if this text is already the active system prompt.
if message.role == "system":
text = "".join(text_bits)
if text == current_system:
return current_system
current_system = text
if attachment_items:
content = []
if text_bits:
content.append({"type": "text", "text": "".join(text_bits)})
content.extend(attachment_items)
entry = {"role": message.role, "content": content}
else:
entry = {
"role": message.role,
"content": "".join(text_bits) if text_bits else None,
}
if tool_calls:
entry["tool_calls"] = tool_calls
# OpenAI expects content=null when only tool_calls are present.
if not text_bits:
entry["content"] = None
elif entry["content"] is None and message.role != "assistant":
# For user/system, an empty message is pointless — drop it.
return current_system
out.append(entry)
return current_system
def build_messages(self, prompt, conversation, image_detail=None):
"""Translate prompt.messages into OpenAI's wire format.
Under the Phase 7 invariant, ``prompt.messages`` is the full
chain for this turn — Conversation.prompt and response.reply
pre-bake the history into it. The ``conversation`` parameter
is unused and retained only for the plugin API contract.
"""
messages: List[Dict[str, Any]] = []
if image_detail is not None:
image_detail = image_detail.value
current_system: Optional[str] = None
for msg in prompt.messages:
current_system = self._append_llm_message(
messages, msg, current_system, image_detail=image_detail
)
return messages
def set_usage(self, response, usage):
if not usage:
return
input_tokens = usage.pop("prompt_tokens")
output_tokens = usage.pop("completion_tokens")
usage.pop("total_tokens")
response.set_usage(
input=input_tokens, output=output_tokens, details=simplify_usage_dict(usage)
)
def get_client(self, key, *, async_=False):
kwargs = {}
if self.api_base:
kwargs["base_url"] = self.api_base
if self.api_type:
kwargs["api_type"] = self.api_type
if self.api_version:
kwargs["api_version"] = self.api_version
if self.api_engine:
kwargs["engine"] = self.api_engine
if self.needs_key:
kwargs["api_key"] = self.get_key(key)
else:
# OpenAI-compatible models don't need a key, but the
# openai client library requires one
kwargs["api_key"] = "DUMMY_KEY"
if self.headers:
kwargs["default_headers"] = self.headers
if os.environ.get("LLM_OPENAI_SHOW_RESPONSES"):
kwargs["http_client"] = logging_client()
if async_:
return openai.AsyncOpenAI(**kwargs)
else:
return openai.OpenAI(**kwargs)
def build_kwargs(self, prompt, stream):
kwargs = dict(not_nulls(prompt.options))
json_object = kwargs.pop("json_object", None)
kwargs.pop("image_detail", None)
if "max_tokens" not in kwargs and self.default_max_tokens is not None:
kwargs["max_tokens"] = self.default_max_tokens
if json_object:
kwargs["response_format"] = {"type": "json_object"}
if prompt.schema:
kwargs["response_format"] = {
"type": "json_schema",
"json_schema": {"name": "output", "schema": prompt.schema},
}
if prompt.tools:
kwargs["tools"] = [
{
"type": "function",
"function": {
"name": tool.name,
"description": tool.description or None,
"parameters": tool.input_schema,
},
}
for tool in prompt.tools
]
if stream:
kwargs["stream_options"] = {"include_usage": True}
return kwargs
class Chat(_Shared, KeyModel):
needs_key = "openai"
key_env_var = "OPENAI_API_KEY"
default_max_tokens = None
Options = build_options_class()
def execute(
self,
prompt: Prompt,
stream: bool,
response: Response,
conversation: Optional[Conversation] = None,
key: Optional[str] = None,
) -> Iterator[Union[str, StreamEvent]]:
if prompt.system and not self.allows_system_prompt:
raise NotImplementedError("Model does not support system prompts")
messages = self.build_messages(
prompt,
conversation,
image_detail=getattr(prompt.options, "image_detail", None),
)
kwargs = self.build_kwargs(prompt, stream)
client = self.get_client(key)
usage = None
if stream:
completion = client.chat.completions.create(
model=self.model_name or self.model_id,
messages=messages,
stream=True,
**kwargs,
)
chunks = []
tool_calls = {}
for chunk in completion:
chunks.append(chunk)
if chunk.usage:
usage = chunk.usage.model_dump()
if chunk.choices and chunk.choices[0].delta:
for tool_call in chunk.choices[0].delta.tool_calls or []:
if tool_call.function.arguments is None:
tool_call.function.arguments = ""
idx = tool_call.index
if idx not in tool_calls:
tool_calls[idx] = tool_call
yield StreamEvent(
type="tool_call_name",
chunk=tool_call.function.name or "",
tool_call_id=tool_call.id,
)
else:
tool_calls[
idx
].function.arguments += tool_call.function.arguments
if tool_call.function.arguments:
yield StreamEvent(
type="tool_call_args",
chunk=tool_call.function.arguments,
tool_call_id=tool_calls[idx].id,
)
try:
content = chunk.choices[0].delta.content
except IndexError:
content = None
if content:
# Empty strings are noise (OpenAI's first chunk
# with role=assistant has content="").
yield StreamEvent(type="text", chunk=content)
response.response_json = remove_dict_none_values(combine_chunks(chunks))
if tool_calls:
for value in tool_calls.values():
response.add_tool_call(
llm.ToolCall(
tool_call_id=value.id,
name=value.function.name,
arguments=json.loads(value.function.arguments),
)
)
else:
completion = client.chat.completions.create(
model=self.model_name or self.model_id,
messages=messages,
stream=False,
**kwargs,
)
usage = completion.usage.model_dump()
response.response_json = remove_dict_none_values(completion.model_dump())
for tool_call in completion.choices[0].message.tool_calls or []:
response.add_tool_call(
llm.ToolCall(
tool_call_id=tool_call.id,
name=tool_call.function.name,
arguments=json.loads(tool_call.function.arguments),
)
)
yield StreamEvent(
type="tool_call_name",
chunk=tool_call.function.name or "",
tool_call_id=tool_call.id,
)
yield StreamEvent(
type="tool_call_args",
chunk=tool_call.function.arguments or "",
tool_call_id=tool_call.id,
)
if completion.choices[0].message.content is not None:
yield StreamEvent(
type="text",
chunk=completion.choices[0].message.content,
)
# Read reasoning_tokens from usage BEFORE set_usage runs —
# set_usage pops top-level keys and passes the rest through
# simplify_usage_dict, which strips zero-valued entries.
if usage:
reasoning_tokens = (usage.get("completion_tokens_details") or {}).get(
"reasoning_tokens", 0
)
if reasoning_tokens:
yield StreamEvent(type="reasoning", chunk="", redacted=True)
self.set_usage(response, usage)
response._prompt_json = redact_data({"messages": messages})
class AsyncChat(_Shared, AsyncKeyModel):
needs_key = "openai"
key_env_var = "OPENAI_API_KEY"
default_max_tokens = None
Options = build_options_class()
async def execute(
self,
prompt: Prompt,
stream: bool,
response: AsyncResponse,
conversation: Optional[AsyncConversation] = None,
key: Optional[str] = None,
) -> AsyncGenerator[Union[str, StreamEvent], None]:
if prompt.system and not self.allows_system_prompt:
raise NotImplementedError("Model does not support system prompts")
messages = self.build_messages(
prompt,
conversation,
image_detail=getattr(prompt.options, "image_detail", None),
)
kwargs = self.build_kwargs(prompt, stream)
client = self.get_client(key, async_=True)
usage = None
if stream:
completion = await client.chat.completions.create(
model=self.model_name or self.model_id,
messages=messages,
stream=True,
**kwargs,
)
chunks = []
tool_calls = {}
async for chunk in completion:
if chunk.usage:
usage = chunk.usage.model_dump()
chunks.append(chunk)
if chunk.choices and chunk.choices[0].delta:
for tool_call in chunk.choices[0].delta.tool_calls or []:
if tool_call.function.arguments is None:
tool_call.function.arguments = ""
idx = tool_call.index
if idx not in tool_calls:
tool_calls[idx] = tool_call
yield StreamEvent(
type="tool_call_name",
chunk=tool_call.function.name or "",
tool_call_id=tool_call.id,
)
else:
tool_calls[
idx
].function.arguments += tool_call.function.arguments
if tool_call.function.arguments:
yield StreamEvent(
type="tool_call_args",
chunk=tool_call.function.arguments,
tool_call_id=tool_calls[idx].id,
)
try:
content = chunk.choices[0].delta.content
except IndexError:
content = None
if content:
yield StreamEvent(type="text", chunk=content)
if tool_calls:
for value in tool_calls.values():
response.add_tool_call(
llm.ToolCall(
tool_call_id=value.id,
name=value.function.name,
arguments=json.loads(value.function.arguments),
)
)
response.response_json = remove_dict_none_values(combine_chunks(chunks))
else:
completion = await client.chat.completions.create(
model=self.model_name or self.model_id,
messages=messages,
stream=False,
**kwargs,
)
response.response_json = remove_dict_none_values(completion.model_dump())
usage = completion.usage.model_dump()
for tool_call in completion.choices[0].message.tool_calls or []:
response.add_tool_call(
llm.ToolCall(
tool_call_id=tool_call.id,
name=tool_call.function.name,
arguments=json.loads(tool_call.function.arguments),
)
)
yield StreamEvent(
type="tool_call_name",
chunk=tool_call.function.name or "",
tool_call_id=tool_call.id,
)
yield StreamEvent(
type="tool_call_args",
chunk=tool_call.function.arguments or "",
tool_call_id=tool_call.id,
)
if completion.choices[0].message.content is not None:
yield StreamEvent(
type="text",
chunk=completion.choices[0].message.content,
)
# See sync Chat.execute: read reasoning before set_usage mutates.
if usage:
reasoning_tokens = (usage.get("completion_tokens_details") or {}).get(
"reasoning_tokens", 0
)
if reasoning_tokens:
yield StreamEvent(type="reasoning", chunk="", redacted=True)
self.set_usage(response, usage)
response._prompt_json = redact_data({"messages": messages})
class Completion(Chat):
class Options(SharedOptions):
logprobs: Optional[int] = Field(
description="Include the log probabilities of most likely N per token",
default=None,
le=5,
)
def __init__(self, *args, default_max_tokens=None, **kwargs):
super().__init__(*args, **kwargs)
self.default_max_tokens = default_max_tokens
def __str__(self) -> str:
return "OpenAI Completion: {}".format(self.model_id)
def execute(
self,
prompt: Prompt,
stream: bool,
response: Response,
conversation: Optional[Conversation] = None,
key: Optional[str] = None,
) -> Iterator[Union[str, StreamEvent]]:
if prompt.system:
raise NotImplementedError(
"System prompts are not supported for OpenAI completion models"
)
messages = []
if conversation is not None:
for prev_response in conversation.responses:
messages.append(prev_response.prompt.prompt)
messages.append(cast(Response, prev_response).text())
messages.append(prompt.prompt)
kwargs = self.build_kwargs(prompt, stream)
client = self.get_client(key)
if stream:
completion = client.completions.create(
model=self.model_name or self.model_id,
prompt="\n".join(messages),
stream=True,
**kwargs,
)
chunks = []
for chunk in completion:
chunks.append(chunk)
try:
content = chunk.choices[0].text
except IndexError:
content = None
if content is not None:
yield content
combined = combine_chunks(chunks)
cleaned = remove_dict_none_values(combined)
response.response_json = cleaned
else:
completion = client.completions.create(
model=self.model_name or self.model_id,
prompt="\n".join(messages),
stream=False,
**kwargs,
)
response.response_json = remove_dict_none_values(completion.model_dump())
yield completion.choices[0].text
response._prompt_json = redact_data({"messages": messages})
def not_nulls(data) -> dict:
return {key: value for key, value in data if value is not None}
def combine_chunks(chunks: List) -> dict:
content = ""
role = None
finish_reason = None
# If any of them have log probability, we're going to persist
# those later on
logprobs = []
usage = {}
for item in chunks:
if item.usage:
usage = item.usage.model_dump()
for choice in item.choices:
if choice.logprobs and hasattr(choice.logprobs, "top_logprobs"):
logprobs.append(
{
"text": choice.text if hasattr(choice, "text") else None,
"top_logprobs": choice.logprobs.top_logprobs,
}
)
if not hasattr(choice, "delta"):
content += choice.text
continue
role = choice.delta.role
if choice.delta.content is not None:
content += choice.delta.content
if choice.finish_reason is not None:
finish_reason = choice.finish_reason
# Imitations of the OpenAI API may be missing some of these fields
combined = {
"content": content,
"role": role,
"finish_reason": finish_reason,
"usage": usage,
}
if logprobs:
combined["logprobs"] = logprobs
if chunks:
for key in ("id", "object", "model", "created", "index"):
value = getattr(chunks[0], key, None)
if value is not None:
combined[key] = value
return combined
def redact_data(input_dict):
"""
Recursively search through the input dictionary for any 'image_url' keys
and modify the 'url' value to be just 'data:...'.
Also redact input_audio.data keys
"""
if isinstance(input_dict, dict):
for key, value in input_dict.items():
if (
key == "image_url"
and isinstance(value, dict)
and "url" in value
and value["url"].startswith("data:")
):
value["url"] = "data:..."
elif key == "input_audio" and isinstance(value, dict) and "data" in value:
value["data"] = "..."
else:
redact_data(value)
elif isinstance(input_dict, list):
for item in input_dict:
redact_data(item)
return input_dict