1"""OpenAI chat wrapper.23!!! warning "API scope"45 `ChatOpenAI` targets6 [official OpenAI API specifications](https://github.com/openai/openai-openapi)7 only. Non-standard response fields added by third-party providers (e.g.,8 `reasoning_content`, `reasoning_details`) are **not** extracted or9 preserved. If you are pointing `base_url` at a provider such as10 OpenRouter, vLLM, or DeepSeek, use the corresponding provider-specific11 LangChain package instead (e.g., `ChatDeepSeek`, `ChatOpenRouter`).12"""1314from __future__ import annotations1516import base6417import json18import logging19import os20import re21import ssl22import sys23import warnings24from collections.abc import (25 AsyncIterator,26 Awaitable,27 Callable,28 Iterator,29 Mapping,30 Sequence,31)32from functools import partial33from io import BytesIO34from json import JSONDecodeError35from math import ceil36from operator import itemgetter37from typing import (38 TYPE_CHECKING,39 Any,40 Literal,41 TypeAlias,42 TypeVar,43 cast,44)45from urllib.parse import urlparse4647import certifi48import openai49import tiktoken50from langchain_core.callbacks import (51 AsyncCallbackManagerForLLMRun,52 CallbackManagerForLLMRun,53)54from langchain_core.exceptions import (55 ContextOverflowError,56 ModelAPIError,57 ModelAuthenticationError,58 ModelConnectionError,59 ModelInvalidRequestError,60 ModelNotFoundError,61 ModelPermissionDeniedError,62 ModelRateLimitError,63 ModelTimeoutError,64)65from langchain_core.language_models import (66 LanguageModelInput,67 ModelProfileRegistry,68)69from langchain_core.language_models.chat_models import (70 BaseChatModel,71 LangSmithParams,72)73from langchain_core.messages import (74 AIMessage,75 AIMessageChunk,76 BaseMessage,77 BaseMessageChunk,78 ChatMessage,79 ChatMessageChunk,80 FunctionMessage,81 FunctionMessageChunk,82 HumanMessage,83 HumanMessageChunk,84 InvalidToolCall,85 SystemMessage,86 SystemMessageChunk,87 ToolCall,88 ToolMessage,89 ToolMessageChunk,90 is_data_content_block,91)92from langchain_core.messages import content as types93from langchain_core.messages.ai import (94 InputTokenDetails,95 OutputTokenDetails,96 UsageMetadata,97)98from langchain_core.messages.block_translators.openai import (99 _convert_from_v03_ai_message,100 convert_to_openai_data_block,101)102from langchain_core.messages.tool import tool_call_chunk103from langchain_core.output_parsers import JsonOutputParser, PydanticOutputParser104from langchain_core.output_parsers.openai_tools import (105 JsonOutputKeyToolsParser,106 PydanticToolsParser,107 make_invalid_tool_call,108 parse_tool_call,109)110from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult111from langchain_core.runnables import (112 Runnable,113 RunnableLambda,114 RunnableMap,115 RunnablePassthrough,116)117from langchain_core.runnables.config import run_in_executor118from langchain_core.tools import BaseTool119from langchain_core.tools.base import _stringify120from langchain_core.utils import get_pydantic_field_names121from langchain_core.utils._gateway import (122 GATEWAY_METADATA_RESPONSE_KEY,123 _parse_gateway_metadata,124 _resolve_gateway_config,125)126from langchain_core.utils.function_calling import (127 convert_to_openai_function,128 convert_to_openai_tool,129)130from langchain_core.utils.pydantic import (131 PydanticBaseModel,132 TypeBaseModel,133 is_basemodel_subclass,134)135from langchain_core.utils.utils import LC_AUTO_PREFIX, _build_model_kwargs, from_env136from pydantic import (137 BaseModel,138 ConfigDict,139 Field,140 SecretStr,141 ValidationError,142 field_validator,143 model_validator,144)145from pydantic.v1 import BaseModel as BaseModelV1146from typing_extensions import Self147148from langchain_openai._version import __version__149from langchain_openai.chat_models._client_utils import (150 _astream_with_chunk_timeout,151 _build_proxied_async_httpx_client,152 _build_proxied_sync_httpx_client,153 _float_env,154 _get_default_async_httpx_client,155 _get_default_httpx_client,156 _log_proxy_env_bypass_once,157 _resolve_socket_options,158 _resolve_sync_and_async_api_keys,159 _should_bypass_socket_options_for_proxy_env,160 _warn_if_proxy_env_shadowed,161)162from langchain_openai.chat_models._compat import (163 _convert_from_v1_to_chat_completions,164 _convert_from_v1_to_responses,165 _convert_to_v03_ai_message,166)167from langchain_openai.data._profiles import _PROFILES168169if TYPE_CHECKING:170 import httpx171 from langchain_core.language_models import ModelProfile172 from openai.types.responses import Response173174logger = logging.getLogger(__name__)175176# This SSL context is equivalent to the default `verify=True`.177# https://www.python-httpx.org/advanced/ssl/#configuring-client-instances178global_ssl_context = ssl.create_default_context(cafile=certifi.where())179180_ssrf_client: httpx.Client | None = None181182183def _get_ssrf_safe_client() -> httpx.Client:184 global _ssrf_client185 if _ssrf_client is None:186 from langchain_core._security._transport import ssrf_safe_client187188 _ssrf_client = ssrf_safe_client(189 verify=global_ssl_context, follow_redirects=False190 )191 return _ssrf_client192193194_MODEL_PROFILES = cast(ModelProfileRegistry, _PROFILES)195196197def _get_default_model_profile(model_name: str) -> ModelProfile:198 default = _MODEL_PROFILES.get(model_name) or {}199 return default.copy()200201202WellKnownTools = (203 "file_search",204 "web_search_preview",205 "web_search",206 "computer_use_preview",207 "code_interpreter",208 "mcp",209 "image_generation",210 "tool_search",211 "apply_patch",212)213214_TOOL_EXTRAS_PASSTHROUGH = ("defer_loading", "async")215216217def _convert_dict_to_message(_dict: Mapping[str, Any]) -> BaseMessage:218 """Convert a dictionary to a LangChain message.219220 Args:221 _dict: The dictionary.222223 Returns:224 The LangChain message.225 """226 role = _dict.get("role")227 name = _dict.get("name")228 id_ = _dict.get("id")229 if role == "user":230 return HumanMessage(content=_dict.get("content", ""), id=id_, name=name)231 if role == "assistant":232 # Fix for azure233 # Also OpenAI returns None for tool invocations234 content = _dict.get("content", "") or ""235 additional_kwargs: dict = {}236 if function_call := _dict.get("function_call"):237 additional_kwargs["function_call"] = dict(function_call)238 tool_calls = []239 invalid_tool_calls = []240 if raw_tool_calls := _dict.get("tool_calls"):241 for raw_tool_call in raw_tool_calls:242 try:243 tool_calls.append(parse_tool_call(raw_tool_call, return_id=True))244 except Exception as e:245 invalid_tool_calls.append(246 make_invalid_tool_call(raw_tool_call, str(e))247 )248 if audio := _dict.get("audio"):249 additional_kwargs["audio"] = audio250 return AIMessage(251 content=content,252 additional_kwargs=additional_kwargs,253 name=name,254 id=id_,255 tool_calls=tool_calls,256 invalid_tool_calls=invalid_tool_calls,257 )258 if role in ("system", "developer"):259 additional_kwargs = {"__openai_role__": role} if role == "developer" else {}260 return SystemMessage(261 content=_dict.get("content", ""),262 name=name,263 id=id_,264 additional_kwargs=additional_kwargs,265 )266 if role == "function":267 return FunctionMessage(268 content=_dict.get("content", ""), name=cast(str, _dict.get("name")), id=id_269 )270 if role == "tool":271 additional_kwargs = {}272 if "name" in _dict:273 additional_kwargs["name"] = _dict["name"]274 return ToolMessage(275 content=_dict.get("content", ""),276 tool_call_id=cast(str, _dict.get("tool_call_id")),277 additional_kwargs=additional_kwargs,278 name=name,279 id=id_,280 )281 return ChatMessage(content=_dict.get("content", ""), role=role, id=id_) # type: ignore[arg-type]282283284def _apply_prompt_cache_breakpoint(285 source_block: dict[str, Any], formatted_block: dict[str, Any]286) -> dict[str, Any]:287 """Apply an OpenAI prompt cache breakpoint to a formatted content block.288289 A breakpoint set directly on the block takes precedence over one nested in290 `extras`. Membership (not truthiness) decides whether to copy it, so a291 present-but-falsy value (e.g. `None`) is still preserved.292 """293 if "prompt_cache_breakpoint" in source_block:294 formatted_block["prompt_cache_breakpoint"] = source_block[295 "prompt_cache_breakpoint"296 ]297 elif isinstance(extras := source_block.get("extras"), dict) and (298 "prompt_cache_breakpoint" in extras299 ):300 formatted_block["prompt_cache_breakpoint"] = extras["prompt_cache_breakpoint"]301 return formatted_block302303304def _sanitize_chat_completions_content(content: str | list[dict]) -> str | list[dict]:305 """Sanitize content for chat/completions API.306307 For list content, filters text blocks to only keep supported keys.308 """309 if isinstance(content, list):310 sanitized = []311 for block in content:312 if (313 isinstance(block, dict)314 and block.get("type") == "text"315 and "text" in block316 ):317 sanitized_block = {"type": "text", "text": block["text"]}318 if "prompt_cache_breakpoint" in block:319 sanitized_block["prompt_cache_breakpoint"] = block[320 "prompt_cache_breakpoint"321 ]322 sanitized.append(sanitized_block)323 else:324 sanitized.append(block)325 return sanitized326 return content327328329def _format_message_content(330 content: Any,331 api: Literal["chat/completions", "responses"] = "chat/completions",332 role: str | None = None,333) -> Any:334 """Format message content."""335 if content and isinstance(content, list):336 formatted_content = []337 for block in content:338 # Remove unexpected block types339 if (340 isinstance(block, dict)341 and "type" in block342 and (343 block["type"] in ("tool_use", "thinking", "reasoning_content")344 or (345 block["type"] in ("function_call", "code_interpreter_call")346 and api == "chat/completions"347 )348 )349 ):350 continue351 if (352 isinstance(block, dict)353 and is_data_content_block(block)354 # Responses API messages handled separately in _compat (parsed into355 # image generation calls)356 and not (api == "responses" and str(role).lower().startswith("ai"))357 ):358 formatted_block = convert_to_openai_data_block(block, api=api)359 formatted_content.append(360 _apply_prompt_cache_breakpoint(block, formatted_block)361 )362 elif (363 isinstance(block, dict)364 and block.get("type") == "text"365 and "text" in block366 and isinstance(extras := block.get("extras"), dict)367 and "prompt_cache_breakpoint" in extras368 ):369 formatted_block = {"type": "text", "text": block["text"]}370 formatted_content.append(371 _apply_prompt_cache_breakpoint(block, formatted_block)372 )373 # Anthropic image blocks374 elif (375 isinstance(block, dict)376 and block.get("type") == "image"377 and (source := block.get("source"))378 and isinstance(source, dict)379 ):380 if source.get("type") == "base64" and (381 (media_type := source.get("media_type"))382 and (data := source.get("data"))383 ):384 formatted_content.append(385 {386 "type": "image_url",387 "image_url": {"url": f"data:{media_type};base64,{data}"},388 }389 )390 elif source.get("type") == "url" and (url := source.get("url")):391 formatted_content.append(392 {"type": "image_url", "image_url": {"url": url}}393 )394 else:395 continue396 else:397 formatted_content.append(block)398 else:399 formatted_content = content400401 return formatted_content402403404def _convert_message_to_dict(405 message: BaseMessage,406 api: Literal["chat/completions", "responses"] = "chat/completions",407) -> dict:408 """Convert a LangChain message to dictionary format expected by OpenAI."""409 message_dict: dict[str, Any] = {410 "content": _format_message_content(message.content, api=api, role=message.type)411 }412 if (name := message.name or message.additional_kwargs.get("name")) is not None:413 message_dict["name"] = name414415 # populate role and additional message data416 if isinstance(message, ChatMessage):417 message_dict["role"] = message.role418 elif isinstance(message, HumanMessage):419 message_dict["role"] = "user"420 elif isinstance(message, AIMessage):421 message_dict["role"] = "assistant"422 if message.tool_calls or message.invalid_tool_calls:423 message_dict["tool_calls"] = [424 _lc_tool_call_to_openai_tool_call(tc) for tc in message.tool_calls425 ] + [426 _lc_invalid_tool_call_to_openai_tool_call(tc)427 for tc in message.invalid_tool_calls428 ]429 elif "tool_calls" in message.additional_kwargs:430 message_dict["tool_calls"] = message.additional_kwargs["tool_calls"]431 tool_call_supported_props = {"id", "type", "function"}432 message_dict["tool_calls"] = [433 {k: v for k, v in tool_call.items() if k in tool_call_supported_props}434 for tool_call in message_dict["tool_calls"]435 ]436 elif "function_call" in message.additional_kwargs:437 # OpenAI raises 400 if both function_call and tool_calls are present in the438 # same message.439 message_dict["function_call"] = message.additional_kwargs["function_call"]440 else:441 pass442 # If tool calls present, content null value should be None not empty string.443 if "function_call" in message_dict or "tool_calls" in message_dict:444 message_dict["content"] = message_dict["content"] or None445446 audio: dict[str, Any] | None = None447 for block in message.content:448 if (449 isinstance(block, dict)450 and block.get("type") == "audio"451 and (id_ := block.get("id"))452 and api != "responses"453 ):454 # openai doesn't support passing the data back - only the id455 # https://platform.openai.com/docs/guides/audio/multi-turn-conversations456 audio = {"id": id_}457 if not audio and "audio" in message.additional_kwargs:458 raw_audio = message.additional_kwargs["audio"]459 audio = (460 {"id": message.additional_kwargs["audio"]["id"]}461 if "id" in raw_audio462 else raw_audio463 )464 if audio:465 message_dict["audio"] = audio466 elif isinstance(message, SystemMessage):467 message_dict["role"] = message.additional_kwargs.get(468 "__openai_role__", "system"469 )470 elif isinstance(message, FunctionMessage):471 message_dict["role"] = "function"472 elif isinstance(message, ToolMessage):473 message_dict["role"] = "tool"474 message_dict["tool_call_id"] = message.tool_call_id475 message_dict["content"] = _sanitize_chat_completions_content(476 message_dict["content"]477 )478 supported_props = {"content", "role", "tool_call_id"}479 message_dict = {k: v for k, v in message_dict.items() if k in supported_props}480 else:481 msg = f"Got unknown type {message}"482 raise TypeError(msg)483 return message_dict484485486def _convert_delta_to_message_chunk(487 _dict: Mapping[str, Any], default_class: type[BaseMessageChunk]488) -> BaseMessageChunk:489 """Convert to a LangChain message chunk."""490 id_ = _dict.get("id")491 role = cast(str, _dict.get("role"))492 content = cast(str, _dict.get("content") or "")493 additional_kwargs: dict = {}494 if _dict.get("function_call"):495 function_call = dict(_dict["function_call"])496 if "name" in function_call and function_call["name"] is None:497 function_call["name"] = ""498 additional_kwargs["function_call"] = function_call499 tool_call_chunks = []500 if raw_tool_calls := _dict.get("tool_calls"):501 try:502 tool_call_chunks = [503 tool_call_chunk(504 name=rtc["function"].get("name"),505 args=rtc["function"].get("arguments"),506 id=rtc.get("id"),507 index=rtc["index"],508 )509 for rtc in raw_tool_calls510 ]511 except KeyError:512 pass513514 if role == "user" or default_class == HumanMessageChunk:515 return HumanMessageChunk(content=content, id=id_)516 if role == "assistant" or default_class == AIMessageChunk:517 return AIMessageChunk(518 content=content,519 additional_kwargs=additional_kwargs,520 id=id_,521 tool_call_chunks=tool_call_chunks, # type: ignore[arg-type]522 )523 if role in ("system", "developer") or default_class == SystemMessageChunk:524 if role == "developer":525 additional_kwargs = {"__openai_role__": "developer"}526 else:527 additional_kwargs = {}528 return SystemMessageChunk(529 content=content, id=id_, additional_kwargs=additional_kwargs530 )531 if role == "function" or default_class == FunctionMessageChunk:532 return FunctionMessageChunk(content=content, name=_dict["name"], id=id_)533 if role == "tool" or default_class == ToolMessageChunk:534 return ToolMessageChunk(535 content=content, tool_call_id=_dict["tool_call_id"], id=id_536 )537 if role or default_class == ChatMessageChunk:538 return ChatMessageChunk(content=content, role=role, id=id_)539 return default_class(content=content, id=id_) # type: ignore[call-arg]540541542def _update_token_usage(543 overall_token_usage: int | dict, new_usage: int | dict544) -> int | dict:545 # Token usage is either ints or dictionaries546 # `reasoning_tokens` is nested inside `completion_tokens_details`547 if isinstance(new_usage, int):548 if not isinstance(overall_token_usage, int):549 msg = (550 f"Got different types for token usage: "551 f"{type(new_usage)} and {type(overall_token_usage)}"552 )553 raise ValueError(msg)554 return new_usage + overall_token_usage555 if isinstance(new_usage, dict):556 if not isinstance(overall_token_usage, dict):557 msg = (558 f"Got different types for token usage: "559 f"{type(new_usage)} and {type(overall_token_usage)}"560 )561 raise ValueError(msg)562 return {563 k: _update_token_usage(overall_token_usage.get(k, 0), v)564 for k, v in new_usage.items()565 }566 warnings.warn(f"Unexpected type for token usage: {type(new_usage)}")567 return new_usage568569570class OpenAIContextOverflowError(openai.BadRequestError, ContextOverflowError):571 """BadRequestError raised when input exceeds OpenAI's context limit."""572573574class OpenAIAPIContextOverflowError(openai.APIError, ContextOverflowError):575 """APIError raised when input exceeds OpenAI's context limit."""576577578class OpenAIAuthenticationError(openai.AuthenticationError, ModelAuthenticationError):579 """OpenAI authentication error classified as a LangChain model error."""580581582class OpenAIPermissionDeniedError(583 openai.PermissionDeniedError, ModelPermissionDeniedError584):585 """OpenAI permission error classified as a LangChain model error."""586587588class OpenAIInvalidRequestError(openai.BadRequestError, ModelInvalidRequestError):589 """OpenAI bad-request error classified as a LangChain model error."""590591592class OpenAIModelNotFoundError(openai.NotFoundError, ModelNotFoundError):593 """OpenAI not-found error classified as a LangChain model error."""594595596class OpenAIRateLimitError(openai.RateLimitError, ModelRateLimitError):597 """OpenAI rate-limit error classified as a LangChain model error."""598599600class OpenAIAPIError(openai.InternalServerError, ModelAPIError):601 """OpenAI server error classified as a LangChain model error."""602603604class OpenAIConnectionError(openai.APIConnectionError, ModelConnectionError):605 """OpenAI connection error classified as a LangChain model error."""606607608class OpenAITimeoutError(openai.APITimeoutError, ModelTimeoutError):609 """OpenAI timeout error classified as a LangChain model error."""610611612def _handle_openai_bad_request(e: openai.BadRequestError) -> None:613 if (614 "context_length_exceeded" in str(e)615 or "Input tokens exceed the configured limit" in e.message616 or "prompt is too long" in e.message617 or "ContextWindowExceededError" in e.message618 ):619 raise OpenAIContextOverflowError(620 message=e.message, response=e.response, body=e.body621 ) from e622 if (623 "'response_format' of type 'json_schema' is not supported with this model"624 ) in e.message:625 message = (626 "This model does not support OpenAI's structured output feature, which "627 "is the default method for `with_structured_output` as of "628 "langchain-openai==0.3. To use `with_structured_output` with this model, "629 'specify `method="function_calling"`.'630 )631 warnings.warn(message)632 raise OpenAIInvalidRequestError(633 message=e.message, response=e.response, body=e.body634 ) from e635 if "Invalid schema for response_format" in e.message:636 message = (637 "Invalid schema for OpenAI's structured output feature, which is the "638 "default method for `with_structured_output` as of langchain-openai==0.3. "639 'Specify `method="function_calling"` instead or update your schema. '640 "See supported schemas: "641 "https://platform.openai.com/docs/guides/structured-outputs#supported-schemas"642 )643 warnings.warn(message)644 raise OpenAIInvalidRequestError(645 message=e.message, response=e.response, body=e.body646 ) from e647648649def _handle_openai_api_error(e: openai.APIError) -> None:650 error_message = str(e)651 if "exceeds the context window" in error_message:652 raise OpenAIAPIContextOverflowError(653 message=e.message, request=e.request, body=e.body654 ) from e655 if isinstance(e, openai.AuthenticationError):656 raise OpenAIAuthenticationError(657 message=e.message, response=e.response, body=e.body658 ) from e659 if isinstance(e, openai.PermissionDeniedError):660 raise OpenAIPermissionDeniedError(661 message=e.message, response=e.response, body=e.body662 ) from e663 if isinstance(e, openai.NotFoundError):664 raise OpenAIModelNotFoundError(665 message=e.message, response=e.response, body=e.body666 ) from e667 if isinstance(e, openai.RateLimitError):668 raise OpenAIRateLimitError(669 message=e.message, response=e.response, body=e.body670 ) from e671 if isinstance(e, openai.InternalServerError):672 raise OpenAIAPIError(message=e.message, response=e.response, body=e.body) from e673 if isinstance(e, openai.APITimeoutError):674 raise OpenAITimeoutError(e.request) from e675 if isinstance(e, openai.APIConnectionError):676 raise OpenAIConnectionError(message=e.message, request=e.request) from e677 raise678679680def _add_gateway_metadata(generation_info: dict[str, Any], raw_response: Any) -> None:681 """Add parsed LangSmith gateway metadata to `generation_info`, if present.682683 Args:684 generation_info: Generation info to mutate in place.685 raw_response: The raw provider response, or None.686 """687 headers = getattr(raw_response, "headers", None)688 if headers is None:689 return690 gateway_metadata = _parse_gateway_metadata(headers)691 if gateway_metadata is not None:692 generation_info[GATEWAY_METADATA_RESPONSE_KEY] = gateway_metadata693694695_RESPONSES_API_ONLY_PREFIXES = (696 "gpt-5-pro",697 "gpt-5.2-pro",698 "gpt-5.4-pro",699 "gpt-5.5-pro",700 "gpt-5.6-sol",701)702703704def _model_prefers_responses_api(model_name: str | None) -> bool:705 if not model_name:706 return False707 return model_name.startswith(_RESPONSES_API_ONLY_PREFIXES) or "codex" in model_name708709710_BM = TypeVar("_BM", bound=BaseModel)711_DictOrPydanticClass: TypeAlias = dict[str, Any] | type[_BM] | type712_DictOrPydantic: TypeAlias = dict | _BM713714715class BaseChatOpenAI(BaseChatModel):716 """Base wrapper around OpenAI large language models for chat.717718 This base class targets719 [official OpenAI API specifications](https://github.com/openai/openai-openapi)720 only. Non-standard response fields added by third-party providers (e.g.,721 `reasoning_content`) are not extracted. Use a provider-specific subclass for722 full provider support.723 """724725 client: Any = Field(default=None, exclude=True)726727 async_client: Any = Field(default=None, exclude=True)728729 root_client: Any = Field(default=None, exclude=True)730731 root_async_client: Any = Field(default=None, exclude=True)732733 model_name: str = Field(default="gpt-3.5-turbo", alias="model")734 """Model name to use."""735736 temperature: float | None = None737 """What sampling temperature to use."""738739 model_kwargs: dict[str, Any] = Field(default_factory=dict)740 """Holds any model parameters valid for `create` call not explicitly specified."""741742 openai_api_key: (743 SecretStr | None | Callable[[], str] | Callable[[], Awaitable[str]]744 ) = Field(alias="api_key", default=None)745 """API key to use.746747 Can be inferred from the `OPENAI_API_KEY` environment variable, or specified748 as a string, or sync or async callable that returns a string.749750 ??? example "Specify with environment variable"751752 ```bash753 export OPENAI_API_KEY=...754 ```755 ```python756 from langchain_openai import ChatOpenAI757758 model = ChatOpenAI(model="gpt-5-nano")759 ```760761 ??? example "Specify with a string"762763 ```python764 from langchain_openai import ChatOpenAI765766 model = ChatOpenAI(model="gpt-5-nano", api_key="...")767 ```768769 ??? example "Specify with a sync callable"770771 ```python772 from langchain_openai import ChatOpenAI773774 def get_api_key() -> str:775 # Custom logic to retrieve API key776 return "..."777778 model = ChatOpenAI(model="gpt-5-nano", api_key=get_api_key)779 ```780781 ??? example "Specify with an async callable"782783 ```python784 from langchain_openai import ChatOpenAI785786 async def get_api_key() -> str:787 # Custom async logic to retrieve API key788 return "..."789790 model = ChatOpenAI(model="gpt-5-nano", api_key=get_api_key)791 ```792 """793794 openai_api_base: str | None = Field(default=None, alias="base_url")795 """Base URL path for API requests, leave blank if not using a proxy or service emulator.796797 Resolution order (first match wins):798799 1. Explicit `base_url` (or `openai_api_base`) kwarg.800 2. Env var `OPENAI_API_BASE` (read by LangChain at init).801 3. Env var `OPENAI_BASE_URL` (read by the underlying `openai` SDK client).802803 `OPENAI_BASE_URL` is also inspected by LangChain only to decide whether to804 default-enable `stream_usage` — when set, the default is left off because many805 non-OpenAI endpoints do not support streaming token usage.806 """ # noqa: E501807808 openai_organization: str | None = Field(default=None, alias="organization")809 """Automatically inferred from env var `OPENAI_ORG_ID` if not provided."""810811 # to support explicit proxy for OpenAI812 openai_proxy: str | None = Field(813 default_factory=from_env("OPENAI_PROXY", default=None)814 )815816 request_timeout: float | tuple[float, float] | Any | None = Field(817 default=None, alias="timeout"818 )819 """Timeout for requests to OpenAI completion API.820821 Can be float, `httpx.Timeout` or `None`.822 """823824 stream_usage: bool | None = None825 """Whether to include usage metadata in streaming output.826827 If enabled, an additional message chunk will be generated during the stream828 including usage metadata.829830 This parameter is enabled unless `openai_api_base` is set or the model is831 initialized with a custom client, as many chat completions APIs do not832 support streaming token usage.833834 !!! version-added "Added in `langchain-openai` 0.3.9"835836 !!! warning "Behavior changed in `langchain-openai` 0.3.35"837838 Enabled for default base URL and client.839 """840841 max_retries: int | None = None842 """Maximum number of retries to make when generating."""843844 presence_penalty: float | None = None845 """Penalizes repeated tokens."""846847 frequency_penalty: float | None = None848 """Penalizes repeated tokens according to frequency."""849850 seed: int | None = None851 """Seed for generation"""852853 logprobs: bool | None = None854 """Whether to return logprobs."""855856 top_logprobs: int | None = None857 """Number of most likely tokens to return at each token position, each with an858 associated log probability.859860 `logprobs` must be set to true if this parameter is used.861 """862863 logit_bias: dict[int, int] | None = None864 """Modify the likelihood of specified tokens appearing in the completion."""865866 streaming: bool = False867 """Whether to stream the results or not."""868869 n: int | None = None870 """Number of chat completions to generate for each prompt."""871872 top_p: float | None = None873 """Total probability mass of tokens to consider at each step."""874875 max_tokens: int | None = Field(default=None)876 """Maximum number of tokens to generate."""877878 reasoning_effort: str | None = None879 """Constrains effort on reasoning for reasoning models.880881 For use with the Chat Completions API. Reasoning models only.882883 Currently supported values are `'minimal'`, `'low'`, `'medium'`, and884 `'high'`. Reducing reasoning effort can result in faster responses and fewer885 tokens used on reasoning in a response.886887 !!! note "Changing reasoning effort mid-conversation"888889 Changing this value part-way through a conversation changes a request-level890 parameter, which invalidates the cached prompt prefix.891892 Models that support it (currently GPT-6) can instead carry the new effort893 in a `configuration_update` item attached to the message that should start894 using it:895896 ```python897 HumanMessage(898 [899 {"type": "configuration_update", "reasoning": {"effort": "high"}},900 {"type": "text", "text": "Analyze the failure modes."},901 ]902 )903 ```904905 The new effort applies from that message onward, until another update906 overrides it.907 """908909 reasoning: dict[str, Any] | None = None910 """Reasoning parameters for reasoning models. None disables reasoning.911912 For use with the Responses API.913914 ```python915 reasoning={916 "effort": None, # Default None; can be "low", "medium", or "high"917 "summary": "auto", # Can be "auto", "concise", or "detailed"918 }919 ```920921 !!! version-added "Added in `langchain-openai` 0.3.24"922 """923924 verbosity: str | None = None925 """Controls the verbosity level of responses for reasoning models.926927 For use with the Responses API.928929 Currently supported values are `'low'`, `'medium'`, and `'high'`.930931 !!! version-added "Added in `langchain-openai` 0.3.28"932 """933934 tiktoken_model_name: str | None = None935 """The model name to pass to tiktoken when using this class.936937 Tiktoken is used to count the number of tokens in documents to constrain938 them to be under a certain limit.939940 By default, when set to `None`, this will be the same as the embedding model name.941 However, there are some cases where you may want to use this `Embedding` class with942 a model name not supported by tiktoken. This can include when using Azure embeddings943 or when using one of the many model providers that expose an OpenAI-like944 API but with different models. In those cases, in order to avoid erroring945 when tiktoken is called, you can specify a model name to use here.946 """947948 default_headers: Mapping[str, str] | None = None949950 default_query: Mapping[str, object] | None = None951952 # Configure a custom httpx client. See the953 # [httpx documentation](https://www.python-httpx.org/api/#client) for more details.954 http_client: Any | None = Field(default=None, exclude=True)955 """Optional `httpx.Client`.956957 Only used for sync invocations. Must specify `http_async_client` as well if958 you'd like a custom client for async invocations.959 """960961 http_async_client: Any | None = Field(default=None, exclude=True)962 """Optional `httpx.AsyncClient`.963964 Only used for async invocations. Must specify `http_client` as well if you'd965 like a custom client for sync invocations.966 """967968 http_socket_options: Sequence[tuple[int, int, int]] | None = Field(969 default=None, exclude=True970 )971 """TCP socket options applied to the httpx transports built by this instance.972973 Defaults to a conservative TCP-keepalive + `TCP_USER_TIMEOUT` profile that974 targets a ~2-minute bound on silent connection hangs (silent mid-stream peer975 loss, gVisor/NAT idle timeouts, silent TCP black holes) on platforms that976 support the full option set. On platforms that only support a subset977 (macOS without `TCP_USER_TIMEOUT`, Windows with only `SO_KEEPALIVE`,978 minimal kernels), unsupported options are silently dropped and the bound979 degrades to whatever the remaining options + OS defaults provide — still980 better than indefinite hang.981982 Accepted values:983984 - `None` (default): use env-driven defaults. Matches the "unset" convention985 used by `http_client` elsewhere on this class.986 - `()` (empty): disable socket-option injection entirely. Inherits the OS987 defaults and restores httpx's native env-proxy auto-detection.988 - A non-empty sequence of `(level, option, value)` tuples: explicit989 override; passed verbatim to the transport (not filtered). Unsupported990 options raise `OSError` at connect time rather than being silently991 dropped — the user chose them explicitly.992993 Environment variables (only consulted when this field is `None`):994 `LANGCHAIN_OPENAI_TCP_KEEPALIVE` (set to `0` to disable entirely — the995 kill-switch), `LANGCHAIN_OPENAI_TCP_KEEPIDLE`,996 `LANGCHAIN_OPENAI_TCP_KEEPINTVL`, `LANGCHAIN_OPENAI_TCP_KEEPCNT`,997 `LANGCHAIN_OPENAI_TCP_USER_TIMEOUT_MS`.998999 Applied per side: if `http_client` is supplied, the sync path uses1000 that user-owned client's socket options as-is; the async path still1001 gets `http_socket_options` applied to its default builder (and1002 vice-versa for `http_async_client`). Supply both to take full control.10031004 !!! note "Interaction with env-proxy auto-detection"10051006 When a custom `httpx` transport is active, `httpx` disables its1007 native env-proxy auto-detection (`HTTP_PROXY` / `HTTPS_PROXY` /1008 `ALL_PROXY` / `NO_PROXY` and macOS/Windows system proxy settings).10091010 To keep the default shape safe, `ChatOpenAI` detects the1011 "proxy-env-shadow" pattern and **skips the custom transport1012 entirely** when **all** of the following hold:10131014 - `http_socket_options` is left at its default (`None`)1015 - No `http_client` or `http_async_client` supplied1016 - No `openai_proxy` supplied1017 - A proxy env var or system proxy is visible to httpx10181019 On that specific shape, the instance falls back to pre-PR behavior1020 and httpx's env-proxy auto-detection applies (a one-time `INFO` log1021 records the bypass for observability).10221023 If you explicitly set `http_socket_options=[...]` while a proxy1024 env var is also set, no bypass — you opted into the transport, and1025 a one-time `WARNING` records the shadowing. Set1026 `http_socket_options=()` or `LANGCHAIN_OPENAI_TCP_KEEPALIVE=0` to1027 disable transport injection explicitly, or pass a fully-configured1028 `http_async_client` / `http_client` to take full control. The1029 `openai_proxy` constructor kwarg is unaffected — socket options1030 are applied cleanly through the proxied transport on that path.1031 """10321033 stream_chunk_timeout: float | None = Field(1034 default_factory=lambda: _float_env(1035 "LANGCHAIN_OPENAI_STREAM_CHUNK_TIMEOUT_S", 120.01036 ),1037 exclude=True,1038 )1039 """Per-chunk wall-clock timeout (seconds) on async streaming responses.10401041 Applies to async invocations only (`astream`, `ainvoke` with streaming,1042 etc.). Sync streaming (`stream`) is not affected.10431044 Fires between content chunks yielded by the openai SDK's streaming iterator1045 (i.e., each call to `__anext__` on the response). Crucially, this is1046 **not** the same as httpx's `timeout.read`:10471048 - httpx's read timeout is inter-byte and gets reset every time *any* bytes1049 arrive on the socket — including OpenAI's SSE keepalive comments1050 (`: keepalive`) that trickle down during long model generations. A1051 stream that's silent on *content* but still producing keepalives looks1052 alive forever to httpx.1053 - `stream_chunk_timeout` measures the gap between *parsed chunks*. The1054 openai SDK's SSE parser consumes keepalive comments internally and does1055 not emit them as chunks, so keepalives do *not* reset this timer. It1056 fires on genuine content silence.10571058 When it fires, a `StreamChunkTimeoutError`1059 (subclass of `asyncio.TimeoutError`) is raised with a self-describing1060 message naming this knob, the env-var override, the model, and the1061 number of chunks received before the stall. A WARNING log with1062 `extra={"source": "stream_chunk_timeout", "timeout_s": <value>,1063 "model_name": <value>, "chunks_received": <value>}` also fires so1064 aggregate logging can distinguish app-layer timeouts from1065 transport-layer failures.10661067 Defaults to 120s. Set to `None` or `0` to disable. Overridable via the1068 `LANGCHAIN_OPENAI_STREAM_CHUNK_TIMEOUT_S` env var. Negative values1069 (from either the env var or the constructor kwarg — e.g., hydrated1070 from YAML/JSON configs) fall back to the default with a `WARNING` log1071 rather than silently disabling the wrapper, so a misconfigured value1072 still boots safely and the fallback is visible.1073 """10741075 stop: list[str] | str | None = Field(default=None, alias="stop_sequences")1076 """Default stop sequences."""10771078 extra_body: Mapping[str, Any] | None = None1079 """Optional additional JSON properties to include in the request parameters1080 when making requests to OpenAI compatible APIs, such as vLLM, LM Studio, or1081 other providers.10821083 This is the recommended way to pass custom parameters that are specific to your1084 OpenAI-compatible API provider but not part of the standard OpenAI API.10851086 Examples:1087 - [LM Studio](https://lmstudio.ai/) TTL parameter: `extra_body={"ttl": 300}`1088 - [vLLM](https://github.com/vllm-project/vllm) custom parameters:1089 `extra_body={"use_beam_search": True}`1090 - Any other provider-specific parameters10911092 !!! warning10931094 Do not use `model_kwargs` for custom parameters that are not part of the1095 standard OpenAI API, as this will cause errors when making API calls. Use1096 `extra_body` instead.1097 """10981099 include_response_headers: bool = False1100 """Whether to include response headers in the output message `response_metadata`.11011102 Note: some inference providers return additional metadata (such as served model1103 names) in the response headers. Enable to capture these metadata.1104 """11051106 disabled_params: dict[str, Any] | None = Field(default=None)1107 """Parameters of the OpenAI client or `chat.completions` endpoint that should be1108 disabled for the given model.11091110 Should be specified as `{"param": None | ['val1', 'val2']}` where the key is the1111 parameter and the value is either None, meaning that parameter should never be1112 used, or it's a list of disabled values for the parameter.11131114 For example, older models may not support the `'parallel_tool_calls'` parameter at1115 all, in which case `disabled_params={"parallel_tool_calls": None}` can be passed1116 in.11171118 If a parameter is disabled then it will not be used by default in any methods, e.g.1119 in `with_structured_output`. However this does not prevent a user from directly1120 passed in the parameter during invocation.1121 """11221123 context_management: list[dict[str, Any]] | None = None1124 """Configuration for1125 [context management](https://developers.openai.com/api/docs/guides/compaction).1126 """11271128 include: list[str] | None = None1129 """Additional fields to include in generations from Responses API.11301131 Supported values:11321133 - `'file_search_call.results'`1134 - `'message.input_image.image_url'`1135 - `'computer_call_output.output.image_url'`1136 - `'reasoning.encrypted_content'`1137 - `'code_interpreter_call.outputs'`11381139 !!! version-added "Added in `langchain-openai` 0.3.24"1140 """11411142 prompt_cache_options: dict[str, Any] | None = None1143 """Options controlling OpenAI prompt cache behavior.11441145 !!! version-added "Added in `langchain-openai` 1.3.5"1146 """11471148 service_tier: str | None = None1149 """Latency tier for request.11501151 Options are `'auto'`, `'default'`, or `'flex'`.11521153 Relevant for users of OpenAI's scale tier service.1154 """11551156 store: bool | None = None1157 """If `True`, OpenAI may store response data for future use.11581159 Defaults to `True` for the Responses API and `False` for the Chat Completions API.11601161 !!! version-added "Added in `langchain-openai` 0.3.24"1162 """11631164 truncation: str | None = None1165 """Truncation strategy (Responses API).11661167 Can be `'auto'` or `'disabled'` (default).11681169 If `'auto'`, model may drop input items from the middle of the message sequence to1170 fit the context window.11711172 !!! version-added "Added in `langchain-openai` 0.3.24"1173 """11741175 use_previous_response_id: bool = False1176 """If `True`, always pass `previous_response_id` using the ID of the most recent1177 response. Responses API only.11781179 Input messages up to the most recent response will be dropped from request1180 payloads.11811182 For example, the following two are equivalent:11831184 ```python1185 model = ChatOpenAI(1186 model="...",1187 use_previous_response_id=True,1188 )1189 model.invoke(1190 [1191 HumanMessage("Hello"),1192 AIMessage("Hi there!", response_metadata={"id": "resp_123"}),1193 HumanMessage("How are you?"),1194 ]1195 )1196 ```11971198 ```python1199 model = ChatOpenAI(model="...", use_responses_api=True)1200 model.invoke([HumanMessage("How are you?")], previous_response_id="resp_123")1201 ```12021203 !!! version-added "Added in `langchain-openai` 0.3.26"1204 """12051206 use_responses_api: bool | None = None1207 """Whether to use the Responses API instead of the Chat API.12081209 If not specified then will be inferred based on invocation params.12101211 !!! version-added "Added in `langchain-openai` 0.3.9"1212 """12131214 output_version: str | None = Field(1215 default_factory=from_env("LC_OUTPUT_VERSION", default=None)1216 )1217 """Version of `AIMessage` output format to use.12181219 This field is used to roll-out new output formats for chat model `AIMessage`1220 responses in a backwards-compatible way.12211222 Supported values:12231224 - `'v0'`: `AIMessage` format as of `langchain-openai 0.3.x`.1225 - `'responses/v1'`: Formats Responses API output items into AIMessage content blocks1226 (Responses API only)1227 - `'v1'`: v1 of LangChain cross-provider standard.12281229 !!! warning "Behavior changed in `langchain-openai` 1.0.0"12301231 Default updated to `"responses/v1"`.1232 """12331234 model_config = ConfigDict(populate_by_name=True)12351236 @property1237 def _uses_gateway(self) -> bool:1238 """Whether requests are routed through the LangSmith gateway.12391240 Detected from the resolved API key: LangSmith keys (used to authenticate1241 to the gateway) carry the `lsv2_` prefix. Callable keys cannot be1242 inspected without invoking them, so they are treated as non-gateway.1243 """1244 api_key = self.openai_api_key1245 if isinstance(api_key, SecretStr):1246 return api_key.get_secret_value().startswith("lsv2_")1247 return False12481249 @property1250 def model(self) -> str:1251 """Same as model_name."""1252 return self.model_name12531254 @model_validator(mode="before")1255 @classmethod1256 def build_extra(cls, values: dict[str, Any]) -> Any:1257 """Build extra kwargs from additional params that were passed in."""1258 all_required_field_names = get_pydantic_field_names(cls)1259 return _build_model_kwargs(values, all_required_field_names)12601261 @field_validator("stream_chunk_timeout", mode="after")1262 @classmethod1263 def _validate_stream_chunk_timeout(cls, value: float | None) -> float | None:1264 """Reject negative constructor values; fall back to the env-driven default.12651266 Matches the env-var path in `_float_env`: a negative value is a typo,1267 not an opt-out (`None`/`0` are the documented off switches). Configs1268 hydrated from YAML/JSON would otherwise silently disable the wrapper1269 and reintroduce the indefinite-stream hang the feature prevents.1270 """1271 if value is not None and value < 0:1272 fallback = _float_env("LANGCHAIN_OPENAI_STREAM_CHUNK_TIMEOUT_S", 120.0)1273 logger.warning(1274 "Invalid `stream_chunk_timeout=%r` (negative); "1275 "falling back to %s. Pass `None` or `0` to disable.",1276 value,1277 fallback,1278 )1279 return fallback1280 return value12811282 @model_validator(mode="before")1283 @classmethod1284 def validate_temperature(cls, values: dict[str, Any]) -> Any:1285 """Validate temperature parameter for different models.12861287 - gpt-5 models (excluding gpt-5-chat) only allow `temperature=1` or unset1288 (Defaults to 1)1289 """1290 model = values.get("model_name") or values.get("model") or ""1291 model_lower = model.lower()12921293 # For o1 models, set temperature=1 if not provided1294 if model_lower.startswith("o1") and "temperature" not in values:1295 values["temperature"] = 112961297 # For gpt-5 models, handle temperature restrictions. Temperature is supported1298 # by gpt-5-chat and gpt-5 models with reasoning_effort='none' or1299 # reasoning={'effort': 'none'}.1300 if (1301 model_lower.startswith("gpt-5")1302 and ("chat" not in model_lower)1303 and values.get("reasoning_effort") != "none"1304 and (values.get("reasoning") or {}).get("effort") != "none"1305 ):1306 temperature = values.get("temperature")1307 if temperature is not None and temperature != 1:1308 # For gpt-5 (non-chat), only temperature=1 is supported1309 # So we remove any non-defaults1310 values.pop("temperature", None)13111312 return values13131314 @model_validator(mode="after")1315 def _set_openai_chat_version(self) -> Self:1316 """Set package version in metadata.13171318 Note: Subclasses that inherit from `BaseChatOpenAI` (e.g.1319 `ChatDeepSeek`, `ChatXAI`) must use a **unique** validator name1320 (e.g. `_set_deepseek_version`) instead of overriding this one. Pydantic1321 replaces same-named `model_validator` methods rather than chaining them,1322 so reusing `_set_openai_chat_version` would silently drop the parent's1323 `langchain-openai` version entry.1324 """1325 self._add_version("langchain-openai", __version__)1326 return self13271328 @model_validator(mode="after")1329 def validate_environment(self) -> Self:1330 """Validate that api key and python package exists in environment."""1331 if self.n is not None and self.n < 1:1332 msg = "n must be at least 1."1333 raise ValueError(msg)1334 if self.n is not None and self.n > 1 and self.streaming:1335 msg = "n must be 1 when streaming."1336 raise ValueError(msg)13371338 # Check OPENAI_ORGANIZATION for backwards compatibility.1339 self.openai_organization = (1340 self.openai_organization1341 or os.getenv("OPENAI_ORG_ID")1342 or os.getenv("OPENAI_ORGANIZATION")1343 )1344 # Resolve base URL and API key, applying LangSmith gateway settings.1345 _gateway_config = _resolve_gateway_config(1346 base_url=self.openai_api_base,1347 api_key=self.openai_api_key,1348 provider_path="openai/v1",1349 base_url_env="OPENAI_API_BASE",1350 api_key_env="OPENAI_API_KEY",1351 )1352 self.openai_api_base = _gateway_config.base_url1353 self.openai_api_key = _gateway_config.api_key1354 _base_url_from_gateway = _gateway_config.base_url_from_gateway13551356 # Enable stream_usage by default if using default base URL and client,1357 # or when the base URL was set by the LangSmith gateway (which proxies1358 # to OpenAI and supports streaming token usage).1359 if all(1360 getattr(self, key, None) is None1361 for key in (1362 "stream_usage",1363 "openai_proxy",1364 "client",1365 "root_client",1366 "async_client",1367 "root_async_client",1368 "http_client",1369 "http_async_client",1370 )1371 ) and (1372 _base_url_from_gateway1373 or (self.openai_api_base is None and "OPENAI_BASE_URL" not in os.environ)1374 ):1375 self.stream_usage = True13761377 # Resolve API key from SecretStr or Callable1378 sync_api_key_value: str | Callable[[], str] | None = None1379 async_api_key_value: str | Callable[[], Awaitable[str]] | None = None13801381 if self.openai_api_key is not None:1382 # Because OpenAI and AsyncOpenAI clients support either sync or async1383 # callables for the API key, we need to resolve separate values here.1384 sync_api_key_value, async_api_key_value = _resolve_sync_and_async_api_keys(1385 self.openai_api_key1386 )13871388 client_params: dict = {1389 "organization": self.openai_organization,1390 "base_url": self.openai_api_base,1391 "timeout": self.request_timeout,1392 "default_headers": self.default_headers,1393 "default_query": self.default_query,1394 }1395 if self.max_retries is not None:1396 client_params["max_retries"] = self.max_retries13971398 if self.openai_proxy and (self.http_client or self.http_async_client):1399 openai_proxy = self.openai_proxy1400 http_client = self.http_client1401 http_async_client = self.http_async_client1402 msg = (1403 "Cannot specify 'openai_proxy' if one of "1404 "'http_client'/'http_async_client' is already specified. Received:\n"1405 f"{openai_proxy=}\n{http_client=}\n{http_async_client=}"1406 )1407 raise ValueError(msg)1408 if _should_bypass_socket_options_for_proxy_env(1409 http_socket_options=self.http_socket_options,1410 http_client=self.http_client,1411 http_async_client=self.http_async_client,1412 openai_proxy=self.openai_proxy,1413 ):1414 # Default-shape construction + proxy env var visible to httpx:1415 # skip the custom transport so httpx's env-proxy auto-detection1416 # still applies. Users who want kernel-level TCP tuning alongside1417 # an env proxy can opt in explicitly via `http_socket_options`.1418 resolved_socket_options: tuple[tuple[int, int, int], ...] = ()1419 _log_proxy_env_bypass_once()1420 else:1421 resolved_socket_options = _resolve_socket_options(self.http_socket_options)1422 _warn_if_proxy_env_shadowed(1423 resolved_socket_options, openai_proxy=self.openai_proxy1424 )1425 if not self.client:1426 if sync_api_key_value is None:1427 # No valid sync API key, leave client as None and raise informative1428 # error on invocation.1429 self.client = None1430 self.root_client = None1431 else:1432 if self.openai_proxy and not self.http_client:1433 self.http_client = _build_proxied_sync_httpx_client(1434 proxy=self.openai_proxy,1435 verify=global_ssl_context,1436 socket_options=resolved_socket_options,1437 )1438 sync_specific = {1439 "http_client": self.http_client1440 or _get_default_httpx_client(1441 self.openai_api_base,1442 self.request_timeout,1443 resolved_socket_options,1444 ),1445 "api_key": sync_api_key_value,1446 }1447 self.root_client = openai.OpenAI(**client_params, **sync_specific) # type: ignore[arg-type]1448 self.client = self.root_client.chat.completions1449 if not self.async_client:1450 if self.openai_proxy and not self.http_async_client:1451 self.http_async_client = _build_proxied_async_httpx_client(1452 proxy=self.openai_proxy,1453 verify=global_ssl_context,1454 socket_options=resolved_socket_options,1455 )1456 async_specific = {1457 "http_client": self.http_async_client1458 or _get_default_async_httpx_client(1459 self.openai_api_base,1460 self.request_timeout,1461 resolved_socket_options,1462 ),1463 "api_key": async_api_key_value,1464 }1465 self.root_async_client = openai.AsyncOpenAI(1466 **client_params,1467 **async_specific, # type: ignore[arg-type]1468 )1469 self.async_client = self.root_async_client.chat.completions1470 return self14711472 def _resolve_model_profile(self) -> ModelProfile | None:1473 return _get_default_model_profile(self.model_name) or None14741475 @property1476 def _default_params(self) -> dict[str, Any]:1477 """Get the default parameters for calling OpenAI API."""1478 exclude_if_none = {1479 "presence_penalty": self.presence_penalty,1480 "frequency_penalty": self.frequency_penalty,1481 "seed": self.seed,1482 "top_p": self.top_p,1483 "logprobs": self.logprobs,1484 "top_logprobs": self.top_logprobs,1485 "logit_bias": self.logit_bias,1486 "stop": self.stop or None, # Also exclude empty list for this1487 "max_tokens": self.max_tokens,1488 "extra_body": self.extra_body,1489 "n": self.n,1490 "temperature": self.temperature,1491 "reasoning_effort": self.reasoning_effort,1492 "reasoning": self.reasoning,1493 "verbosity": self.verbosity,1494 "context_management": self.context_management,1495 "include": self.include,1496 "prompt_cache_options": self.prompt_cache_options,1497 "service_tier": self.service_tier,1498 "truncation": self.truncation,1499 "store": self.store,1500 }15011502 return {1503 "model": self.model_name,1504 "stream": self.streaming,1505 **{k: v for k, v in exclude_if_none.items() if v is not None},1506 **self.model_kwargs,1507 }15081509 def _combine_llm_outputs(self, llm_outputs: list[dict | None]) -> dict:1510 overall_token_usage: dict = {}1511 system_fingerprint = None1512 for output in llm_outputs:1513 if output is None:1514 # Happens in streaming1515 continue1516 token_usage = output.get("token_usage")1517 if token_usage is not None:1518 for k, v in token_usage.items():1519 if v is None:1520 continue1521 if k in overall_token_usage:1522 overall_token_usage[k] = _update_token_usage(1523 overall_token_usage[k], v1524 )1525 else:1526 overall_token_usage[k] = v1527 if system_fingerprint is None:1528 system_fingerprint = output.get("system_fingerprint")1529 combined = {"token_usage": overall_token_usage, "model_name": self.model_name}1530 if system_fingerprint:1531 combined["system_fingerprint"] = system_fingerprint1532 return combined15331534 def _convert_chunk_to_generation_chunk(1535 self,1536 chunk: dict,1537 default_chunk_class: type,1538 base_generation_info: dict | None,1539 ) -> ChatGenerationChunk | None:1540 if chunk.get("type") == "content.delta": # From beta.chat.completions.stream1541 return None1542 token_usage = chunk.get("usage")1543 choices = (1544 chunk.get("choices", [])1545 # From beta.chat.completions.stream1546 or chunk.get("chunk", {}).get("choices", [])1547 )15481549 usage_metadata: UsageMetadata | None = (1550 _create_usage_metadata(token_usage, chunk.get("service_tier"))1551 if token_usage1552 else None1553 )1554 if len(choices) == 0:1555 # logprobs is implicitly None1556 generation_chunk = ChatGenerationChunk(1557 message=default_chunk_class(content="", usage_metadata=usage_metadata),1558 generation_info=base_generation_info,1559 )1560 # Keep content as "" (the default) rather than converting to [].1561 # Chat Completions content deltas are normalized to strings in1562 # _convert_delta_to_message_chunk. Starting with [] causes1563 # merge_content to silently drop string content (empty list is1564 # falsy, so no merge branch applies). The empty list also triggers1565 # the content_blocks isinstance(list) short-circuit, which would1566 # return [] and miss tool_call_chunks.1567 if self.output_version == "v1":1568 generation_chunk.message.response_metadata["output_version"] = "v1"15691570 return generation_chunk15711572 choice = choices[0]1573 if choice["delta"] is None:1574 return None15751576 message_chunk = _convert_delta_to_message_chunk(1577 choice["delta"], default_chunk_class1578 )1579 generation_info = {**base_generation_info} if base_generation_info else {}15801581 if finish_reason := choice.get("finish_reason"):1582 generation_info["finish_reason"] = finish_reason1583 if model_name := chunk.get("model"):1584 generation_info["model_name"] = model_name1585 if system_fingerprint := chunk.get("system_fingerprint"):1586 generation_info["system_fingerprint"] = system_fingerprint1587 if service_tier := chunk.get("service_tier"):1588 generation_info["service_tier"] = service_tier15891590 logprobs = choice.get("logprobs")1591 if logprobs:1592 generation_info["logprobs"] = logprobs15931594 if usage_metadata and isinstance(message_chunk, AIMessageChunk):1595 message_chunk.usage_metadata = usage_metadata15961597 message_chunk.response_metadata["model_provider"] = "openai"1598 # Propagate output_version so content_blocks can detect v1 mode.1599 if self.output_version == "v1":1600 message_chunk.response_metadata["output_version"] = "v1"1601 return ChatGenerationChunk(1602 message=message_chunk, generation_info=generation_info or None1603 )16041605 def _ensure_sync_client_available(self) -> None:1606 """Check that sync client is available, raise error if not."""1607 if self.client is None:1608 msg = (1609 "Sync client is not available. This happens when an async callable "1610 "was provided for the API key. Use async methods (ainvoke, astream) "1611 "instead, or provide a string or sync callable for the API key."1612 )1613 raise ValueError(msg)16141615 def _stream_responses(1616 self,1617 messages: list[BaseMessage],1618 stop: list[str] | None = None,1619 run_manager: CallbackManagerForLLMRun | None = None,1620 **kwargs: Any,1621 ) -> Iterator[ChatGenerationChunk]:1622 self._ensure_sync_client_available()1623 kwargs["stream"] = True1624 payload = self._get_request_payload(messages, stop=stop, **kwargs)1625 headers: dict = {}1626 base_generation_info: dict = {}1627 try:1628 if self.include_response_headers or self._uses_gateway:1629 raw_context_manager = (1630 self.root_client.with_raw_response.responses.create(**payload)1631 )1632 context_manager = raw_context_manager.parse()1633 if self.include_response_headers:1634 headers = {"headers": dict(raw_context_manager.headers)}1635 _add_gateway_metadata(base_generation_info, raw_context_manager)1636 else:1637 context_manager = self.root_client.responses.create(**payload)1638 original_schema_obj = kwargs.get("response_format")16391640 with context_manager as response:1641 is_first_chunk = True1642 current_index = -11643 current_output_index = -11644 current_sub_index = -11645 has_reasoning = False1646 for chunk in response:1647 metadata = headers if is_first_chunk else {}1648 (1649 current_index,1650 current_output_index,1651 current_sub_index,1652 generation_chunk,1653 ) = _convert_responses_chunk_to_generation_chunk(1654 chunk,1655 current_index,1656 current_output_index,1657 current_sub_index,1658 schema=original_schema_obj,1659 metadata=metadata,1660 has_reasoning=has_reasoning,1661 output_version=self.output_version,1662 )1663 if generation_chunk:1664 if is_first_chunk and base_generation_info:1665 generation_chunk.generation_info = {1666 **base_generation_info,1667 **(generation_chunk.generation_info or {}),1668 }1669 if run_manager:1670 run_manager.on_llm_new_token(1671 generation_chunk.text, chunk=generation_chunk1672 )1673 is_first_chunk = False1674 if "reasoning" in generation_chunk.message.additional_kwargs:1675 has_reasoning = True1676 yield generation_chunk1677 except openai.BadRequestError as e:1678 _handle_openai_bad_request(e)1679 except openai.APIError as e:1680 _handle_openai_api_error(e)16811682 async def _astream_responses(1683 self,1684 messages: list[BaseMessage],1685 stop: list[str] | None = None,1686 run_manager: AsyncCallbackManagerForLLMRun | None = None,1687 **kwargs: Any,1688 ) -> AsyncIterator[ChatGenerationChunk]:1689 kwargs["stream"] = True1690 payload = self._get_request_payload(messages, stop=stop, **kwargs)1691 headers: dict = {}1692 base_generation_info: dict = {}1693 try:1694 if self.include_response_headers or self._uses_gateway:1695 raw_context_manager = (1696 await self.root_async_client.with_raw_response.responses.create(1697 **payload1698 )1699 )1700 context_manager = raw_context_manager.parse()1701 if self.include_response_headers:1702 headers = {"headers": dict(raw_context_manager.headers)}1703 _add_gateway_metadata(base_generation_info, raw_context_manager)1704 else:1705 context_manager = await self.root_async_client.responses.create(1706 **payload1707 )1708 original_schema_obj = kwargs.get("response_format")17091710 async with context_manager as response:1711 is_first_chunk = True1712 current_index = -11713 current_output_index = -11714 current_sub_index = -11715 has_reasoning = False1716 async for chunk in _astream_with_chunk_timeout(1717 response,1718 self.stream_chunk_timeout,1719 model_name=self.model_name,1720 ):1721 metadata = headers if is_first_chunk else {}1722 (1723 current_index,1724 current_output_index,1725 current_sub_index,1726 generation_chunk,1727 ) = _convert_responses_chunk_to_generation_chunk(1728 chunk,1729 current_index,1730 current_output_index,1731 current_sub_index,1732 schema=original_schema_obj,1733 metadata=metadata,1734 has_reasoning=has_reasoning,1735 output_version=self.output_version,1736 )1737 if generation_chunk:1738 if is_first_chunk and base_generation_info:1739 generation_chunk.generation_info = {1740 **base_generation_info,1741 **(generation_chunk.generation_info or {}),1742 }1743 if run_manager:1744 await run_manager.on_llm_new_token(1745 generation_chunk.text, chunk=generation_chunk1746 )1747 is_first_chunk = False1748 if "reasoning" in generation_chunk.message.additional_kwargs:1749 has_reasoning = True1750 yield generation_chunk1751 except openai.BadRequestError as e:1752 _handle_openai_bad_request(e)1753 except openai.APIError as e:1754 _handle_openai_api_error(e)17551756 def _should_stream_usage(1757 self, stream_usage: bool | None = None, **kwargs: Any1758 ) -> bool:1759 """Determine whether to include usage metadata in streaming output.17601761 For backwards compatibility, we check for `stream_options` passed1762 explicitly to kwargs or in the `model_kwargs` and override `self.stream_usage`.1763 """1764 stream_usage_sources = [ # order of precedence1765 stream_usage,1766 kwargs.get("stream_options", {}).get("include_usage"),1767 self.model_kwargs.get("stream_options", {}).get("include_usage"),1768 self.stream_usage,1769 ]1770 for source in stream_usage_sources:1771 if isinstance(source, bool):1772 return source1773 return self.stream_usage or False17741775 def _stream(1776 self,1777 messages: list[BaseMessage],1778 stop: list[str] | None = None,1779 run_manager: CallbackManagerForLLMRun | None = None,1780 *,1781 stream_usage: bool | None = None,1782 **kwargs: Any,1783 ) -> Iterator[ChatGenerationChunk]:1784 self._ensure_sync_client_available()1785 kwargs["stream"] = True1786 stream_usage = self._should_stream_usage(stream_usage, **kwargs)1787 if stream_usage:1788 kwargs["stream_options"] = {"include_usage": stream_usage}1789 payload = self._get_request_payload(messages, stop=stop, **kwargs)1790 default_chunk_class: type[BaseMessageChunk] = AIMessageChunk1791 base_generation_info = {}17921793 try:1794 if "response_format" in payload:1795 if self.include_response_headers:1796 warnings.warn(1797 "Cannot currently include response headers when "1798 "response_format is specified."1799 )1800 payload.pop("stream")1801 response_stream = self.root_client.beta.chat.completions.stream(1802 **payload1803 )1804 context_manager = response_stream1805 else:1806 if self.include_response_headers or self._uses_gateway:1807 raw_response = self.client.with_raw_response.create(**payload)1808 response = raw_response.parse()1809 if self.include_response_headers:1810 base_generation_info = {"headers": dict(raw_response.headers)}1811 _add_gateway_metadata(base_generation_info, raw_response)1812 else:1813 response = self.client.create(**payload)1814 context_manager = response1815 with context_manager as response:1816 is_first_chunk = True1817 for chunk in response:1818 if not isinstance(chunk, dict):1819 chunk = chunk.model_dump()1820 generation_chunk = self._convert_chunk_to_generation_chunk(1821 chunk,1822 default_chunk_class,1823 base_generation_info if is_first_chunk else {},1824 )1825 if generation_chunk is None:1826 continue1827 default_chunk_class = generation_chunk.message.__class__1828 logprobs = (generation_chunk.generation_info or {}).get("logprobs")1829 if run_manager:1830 run_manager.on_llm_new_token(1831 generation_chunk.text,1832 chunk=generation_chunk,1833 logprobs=logprobs,1834 )1835 is_first_chunk = False1836 yield generation_chunk1837 except openai.BadRequestError as e:1838 _handle_openai_bad_request(e)1839 except openai.APIError as e:1840 _handle_openai_api_error(e)1841 if hasattr(response, "get_final_completion") and "response_format" in payload:1842 final_completion = response.get_final_completion()1843 generation_chunk = self._get_generation_chunk_from_completion(1844 final_completion1845 )1846 if run_manager:1847 run_manager.on_llm_new_token(1848 generation_chunk.text, chunk=generation_chunk1849 )1850 yield generation_chunk18511852 def _generate(1853 self,1854 messages: list[BaseMessage],1855 stop: list[str] | None = None,1856 run_manager: CallbackManagerForLLMRun | None = None,1857 **kwargs: Any,1858 ) -> ChatResult:1859 self._ensure_sync_client_available()1860 payload = self._get_request_payload(messages, stop=stop, **kwargs)1861 generation_info = None1862 raw_response = None1863 try:1864 if "response_format" in payload:1865 payload.pop("stream")1866 raw_response = (1867 self.root_client.chat.completions.with_raw_response.parse(**payload)1868 )1869 response = raw_response.parse()1870 elif self._use_responses_api(payload):1871 original_schema_obj = kwargs.get("response_format")1872 if original_schema_obj and _is_pydantic_class(original_schema_obj):1873 raw_response = self.root_client.responses.with_raw_response.parse(1874 **payload1875 )1876 else:1877 raw_response = self.root_client.responses.with_raw_response.create(1878 **payload1879 )1880 response = raw_response.parse()1881 if self.include_response_headers:1882 generation_info = {"headers": dict(raw_response.headers)}1883 generation_info = generation_info or {}1884 _add_gateway_metadata(generation_info, raw_response)1885 # Gateway metadata belongs on `generation_info`, not the message1886 # `response_metadata` that `metadata` populates.1887 gateway_metadata = generation_info.pop(1888 GATEWAY_METADATA_RESPONSE_KEY, None1889 )1890 result = _construct_lc_result_from_responses_api(1891 response,1892 schema=original_schema_obj,1893 metadata=generation_info,1894 output_version=self.output_version,1895 )1896 if gateway_metadata is not None:1897 for generation in result.generations:1898 generation.generation_info = generation.generation_info or {}1899 generation.generation_info[GATEWAY_METADATA_RESPONSE_KEY] = (1900 gateway_metadata1901 )1902 return result1903 else:1904 raw_response = self.client.with_raw_response.create(**payload)1905 response = raw_response.parse()1906 except openai.BadRequestError as e:1907 _handle_openai_bad_request(e)1908 except openai.APIError as e:1909 _handle_openai_api_error(e)1910 except Exception as e:1911 if raw_response is not None and hasattr(raw_response, "http_response"):1912 e.response = raw_response.http_response # type: ignore[attr-defined]1913 raise e1914 if (1915 self.include_response_headers1916 and raw_response is not None1917 and hasattr(raw_response, "headers")1918 ):1919 generation_info = {"headers": dict(raw_response.headers)}1920 generation_info = generation_info or {}1921 _add_gateway_metadata(generation_info, raw_response)1922 return self._create_chat_result(response, generation_info)19231924 def _use_responses_api(self, payload: dict) -> bool:1925 if isinstance(self.use_responses_api, bool):1926 return self.use_responses_api1927 if (1928 self.output_version == "responses/v1"1929 or self.context_management is not None1930 or self.include is not None1931 or self.reasoning is not None1932 or self.truncation is not None1933 or self.use_previous_response_id1934 or _model_prefers_responses_api(self.model_name)1935 or (1936 (self.model_name or "").lower().startswith("gpt-6")1937 and payload.get("tools")1938 )1939 ):1940 return True1941 return _use_responses_api(payload)19421943 def _get_request_payload(1944 self,1945 input_: LanguageModelInput,1946 *,1947 stop: list[str] | None = None,1948 **kwargs: Any,1949 ) -> dict:1950 messages = self._convert_input(input_).to_messages()1951 if stop is not None:1952 kwargs["stop"] = stop19531954 payload = {**self._default_params, **kwargs}19551956 if self._use_responses_api(payload):1957 if self.use_previous_response_id:1958 last_messages, previous_response_id = _get_last_messages(messages)1959 payload_to_use = last_messages if previous_response_id else messages1960 if previous_response_id:1961 payload["previous_response_id"] = previous_response_id1962 payload = _construct_responses_api_payload(payload_to_use, payload)1963 else:1964 payload = _construct_responses_api_payload(messages, payload)1965 else:1966 payload["messages"] = [1967 _convert_message_to_dict(_convert_from_v1_to_chat_completions(m))1968 if isinstance(m, AIMessage)1969 else _convert_message_to_dict(m)1970 for m in messages1971 ]1972 return payload19731974 def _create_chat_result(1975 self,1976 response: dict | openai.BaseModel,1977 generation_info: dict | None = None,1978 ) -> ChatResult:1979 generations = []19801981 if not isinstance(response, dict | openai.BaseModel):1982 # `parse()` yields a `str` when the endpoint returns a non-JSON body,1983 # e.g. an HTML error page served after a redirect.1984 preview = repr(response)1985 if len(preview) > 200:1986 preview = f"{preview[:200]}..."1987 msg = (1988 "Unexpected response type from OpenAI-compatible endpoint. "1989 "Expected a dict or openai.BaseModel, got "1990 f"{type(response).__name__}: {preview}"1991 )1992 raise ValueError(msg)19931994 response_dict = (1995 response1996 if isinstance(response, dict)1997 # `parsed` may hold arbitrary Pydantic models from structured output.1998 # Exclude it from this dump and copy it from the typed response below.1999 else response.model_dump(2000 exclude={"choices": {"__all__": {"message": {"parsed"}}}},
Findings
✓ No findings reported for this file.