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 _unwrap_non_standard,167)168from langchain_openai.data._profiles import _PROFILES169170if TYPE_CHECKING:171 import httpx172 from langchain_core.language_models import ModelProfile173 from openai.types.responses import Response174175logger = logging.getLogger(__name__)176177# This SSL context is equivalent to the default `verify=True`.178# https://www.python-httpx.org/advanced/ssl/#configuring-client-instances179global_ssl_context = ssl.create_default_context(cafile=certifi.where())180181_ssrf_client: httpx.Client | None = None182183184def _get_ssrf_safe_client() -> httpx.Client:185 global _ssrf_client186 if _ssrf_client is None:187 from langchain_core._security._transport import ssrf_safe_client188189 _ssrf_client = ssrf_safe_client(190 verify=global_ssl_context, follow_redirects=False191 )192 return _ssrf_client193194195_MODEL_PROFILES = cast(ModelProfileRegistry, _PROFILES)196197198def _get_default_model_profile(model_name: str) -> ModelProfile:199 default = _MODEL_PROFILES.get(model_name) or {}200 return default.copy()201202203WellKnownTools = (204 "file_search",205 "web_search_preview",206 "web_search",207 "computer_use_preview",208 "code_interpreter",209 "mcp",210 "image_generation",211 "tool_search",212 "apply_patch",213)214215_TOOL_EXTRAS_PASSTHROUGH = ("defer_loading", "async")216217218def _convert_dict_to_message(_dict: Mapping[str, Any]) -> BaseMessage:219 """Convert a dictionary to a LangChain message.220221 Args:222 _dict: The dictionary.223224 Returns:225 The LangChain message.226 """227 role = _dict.get("role")228 name = _dict.get("name")229 id_ = _dict.get("id")230 if role == "user":231 return HumanMessage(content=_dict.get("content", ""), id=id_, name=name)232 if role == "assistant":233 # Fix for azure234 # Also OpenAI returns None for tool invocations235 content = _dict.get("content", "") or ""236 additional_kwargs: dict = {}237 if function_call := _dict.get("function_call"):238 additional_kwargs["function_call"] = dict(function_call)239 tool_calls = []240 invalid_tool_calls = []241 if raw_tool_calls := _dict.get("tool_calls"):242 for raw_tool_call in raw_tool_calls:243 try:244 tool_calls.append(parse_tool_call(raw_tool_call, return_id=True))245 except Exception as e:246 invalid_tool_calls.append(247 make_invalid_tool_call(raw_tool_call, str(e))248 )249 if audio := _dict.get("audio"):250 additional_kwargs["audio"] = audio251 return AIMessage(252 content=content,253 additional_kwargs=additional_kwargs,254 name=name,255 id=id_,256 tool_calls=tool_calls,257 invalid_tool_calls=invalid_tool_calls,258 )259 if role in ("system", "developer"):260 additional_kwargs = {"__openai_role__": role} if role == "developer" else {}261 return SystemMessage(262 content=_dict.get("content", ""),263 name=name,264 id=id_,265 additional_kwargs=additional_kwargs,266 )267 if role == "function":268 return FunctionMessage(269 content=_dict.get("content", ""), name=cast(str, _dict.get("name")), id=id_270 )271 if role == "tool":272 additional_kwargs = {}273 if "name" in _dict:274 additional_kwargs["name"] = _dict["name"]275 return ToolMessage(276 content=_dict.get("content", ""),277 tool_call_id=cast(str, _dict.get("tool_call_id")),278 additional_kwargs=additional_kwargs,279 name=name,280 id=id_,281 )282 return ChatMessage(content=_dict.get("content", ""), role=role, id=id_) # type: ignore[arg-type]283284285def _apply_prompt_cache_breakpoint(286 source_block: dict[str, Any], formatted_block: dict[str, Any]287) -> dict[str, Any]:288 """Apply an OpenAI prompt cache breakpoint to a formatted content block.289290 A breakpoint set directly on the block takes precedence over one nested in291 `extras`. Membership (not truthiness) decides whether to copy it, so a292 present-but-falsy value (e.g. `None`) is still preserved.293 """294 if "prompt_cache_breakpoint" in source_block:295 formatted_block["prompt_cache_breakpoint"] = source_block[296 "prompt_cache_breakpoint"297 ]298 elif isinstance(extras := source_block.get("extras"), dict) and (299 "prompt_cache_breakpoint" in extras300 ):301 formatted_block["prompt_cache_breakpoint"] = extras["prompt_cache_breakpoint"]302 return formatted_block303304305def _sanitize_chat_completions_content(content: str | list[dict]) -> str | list[dict]:306 """Sanitize content for chat/completions API.307308 For list content, filters text blocks to only keep supported keys.309 """310 if isinstance(content, list):311 sanitized = []312 for block in content:313 if (314 isinstance(block, dict)315 and block.get("type") == "text"316 and "text" in block317 ):318 sanitized_block = {"type": "text", "text": block["text"]}319 if "prompt_cache_breakpoint" in block:320 sanitized_block["prompt_cache_breakpoint"] = block[321 "prompt_cache_breakpoint"322 ]323 sanitized.append(sanitized_block)324 else:325 sanitized.append(block)326 return sanitized327 return content328329330_ADDITIONAL_TOOLS_BLOCK_TYPE = "additional_tools"331"""Responses API input item that adds tools partway through a conversation."""332333334def _is_ai_role(role: str | None) -> bool:335 """Return whether a message's role is the assistant's.336337 Assistant content is replayed model output, not a caller's instruction, so it is338 exempt from the placement checks a caller's own blocks are held to.339 """340 return str(role).lower().startswith("ai")341342343def _is_system_role(role: str | None) -> bool:344 """Return whether a message's role carries provider instructions.345346 `SystemMessage` reports `"system"` whether or not it is later emitted with347 OpenAI's `developer` role, so one check covers both spellings.348 """349 return role in ("system", "developer")350351352def _raise_if_additional_tools(content: Any, reason: str) -> None:353 """Reject an `additional_tools` block that cannot work where it was placed.354355 The block only reaches the wire as a Responses top-level input item carried on356 a system message. Anywhere else it is this provider's own block type in a357 position this provider forbids, which the error taxonomy makes loud rather than358 silent: nothing routes a request to the Responses API based on message content,359 so a silent drop would make the broken case the default outcome.360361 Args:362 content: The message's content.363 reason: Sentence explaining why this placement cannot work, and how to fix364 it. Appended to the error.365366 Raises:367 ValueError: If an `additional_tools` block is present, in either spelling.368 """369 if not isinstance(content, list):370 return371 for raw_block in content:372 if (373 isinstance(raw_block, dict)374 and _unwrap_non_standard(raw_block).get("type")375 == _ADDITIONAL_TOOLS_BLOCK_TYPE376 ):377 msg = f"`additional_tools` {reason}"378 raise ValueError(msg)379380381def _format_message_content(382 content: Any,383 api: Literal["chat/completions", "responses"] = "chat/completions",384 role: str | None = None,385) -> Any:386 """Format message content."""387 if _is_ai_role(role):388 # Replayed assistant output; `additional_tools` is also an output item, so389 # an echoed one must survive a round trip rather than abort the request.390 pass391 elif not _is_system_role(role):392 _raise_if_additional_tools(393 content,394 "must be carried on a `SystemMessage`. OpenAI restricts the input item "395 'to `role: "developer"`, so it cannot be sent on any other message.',396 )397 elif api == "chat/completions":398 _raise_if_additional_tools(399 content,400 "requires the Responses API and cannot be sent via Chat Completions. "401 "Set `use_responses_api=True`.",402 )403 if content and isinstance(content, list):404 formatted_content = []405 for block in content:406 # Remove unexpected block types407 if (408 isinstance(block, dict)409 and "type" in block410 and (411 block["type"] in ("tool_use", "thinking", "reasoning_content")412 or (413 block["type"] in ("function_call", "code_interpreter_call")414 and api == "chat/completions"415 )416 )417 ):418 continue419 if (420 isinstance(block, dict)421 and is_data_content_block(block)422 # Responses API messages handled separately in _compat (parsed into423 # image generation calls)424 and not (api == "responses" and str(role).lower().startswith("ai"))425 ):426 formatted_block = convert_to_openai_data_block(block, api=api)427 formatted_content.append(428 _apply_prompt_cache_breakpoint(block, formatted_block)429 )430 elif (431 isinstance(block, dict)432 and block.get("type") == "text"433 and "text" in block434 and isinstance(extras := block.get("extras"), dict)435 and "prompt_cache_breakpoint" in extras436 ):437 formatted_block = {"type": "text", "text": block["text"]}438 formatted_content.append(439 _apply_prompt_cache_breakpoint(block, formatted_block)440 )441 # Anthropic image blocks442 elif (443 isinstance(block, dict)444 and block.get("type") == "image"445 and (source := block.get("source"))446 and isinstance(source, dict)447 ):448 if source.get("type") == "base64" and (449 (media_type := source.get("media_type"))450 and (data := source.get("data"))451 ):452 formatted_content.append(453 {454 "type": "image_url",455 "image_url": {"url": f"data:{media_type};base64,{data}"},456 }457 )458 elif source.get("type") == "url" and (url := source.get("url")):459 formatted_content.append(460 {"type": "image_url", "image_url": {"url": url}}461 )462 else:463 continue464 else:465 formatted_content.append(block)466 else:467 formatted_content = content468469 return formatted_content470471472def _convert_message_to_dict(473 message: BaseMessage,474 api: Literal["chat/completions", "responses"] = "chat/completions",475) -> dict:476 """Convert a LangChain message to dictionary format expected by OpenAI."""477 message_dict: dict[str, Any] = {478 "content": _format_message_content(message.content, api=api, role=message.type)479 }480 if (name := message.name or message.additional_kwargs.get("name")) is not None:481 message_dict["name"] = name482483 # populate role and additional message data484 if isinstance(message, ChatMessage):485 message_dict["role"] = message.role486 elif isinstance(message, HumanMessage):487 message_dict["role"] = "user"488 elif isinstance(message, AIMessage):489 message_dict["role"] = "assistant"490 if message.tool_calls or message.invalid_tool_calls:491 message_dict["tool_calls"] = [492 _lc_tool_call_to_openai_tool_call(tc) for tc in message.tool_calls493 ] + [494 _lc_invalid_tool_call_to_openai_tool_call(tc)495 for tc in message.invalid_tool_calls496 ]497 elif "tool_calls" in message.additional_kwargs:498 message_dict["tool_calls"] = message.additional_kwargs["tool_calls"]499 tool_call_supported_props = {"id", "type", "function"}500 message_dict["tool_calls"] = [501 {k: v for k, v in tool_call.items() if k in tool_call_supported_props}502 for tool_call in message_dict["tool_calls"]503 ]504 elif "function_call" in message.additional_kwargs:505 # OpenAI raises 400 if both function_call and tool_calls are present in the506 # same message.507 message_dict["function_call"] = message.additional_kwargs["function_call"]508 else:509 pass510 # If tool calls present, content null value should be None not empty string.511 if "function_call" in message_dict or "tool_calls" in message_dict:512 message_dict["content"] = message_dict["content"] or None513514 audio: dict[str, Any] | None = None515 for block in message.content:516 if (517 isinstance(block, dict)518 and block.get("type") == "audio"519 and (id_ := block.get("id"))520 and api != "responses"521 ):522 # openai doesn't support passing the data back - only the id523 # https://platform.openai.com/docs/guides/audio/multi-turn-conversations524 audio = {"id": id_}525 if not audio and "audio" in message.additional_kwargs:526 raw_audio = message.additional_kwargs["audio"]527 audio = (528 {"id": message.additional_kwargs["audio"]["id"]}529 if "id" in raw_audio530 else raw_audio531 )532 if audio:533 message_dict["audio"] = audio534 elif isinstance(message, SystemMessage):535 message_dict["role"] = message.additional_kwargs.get(536 "__openai_role__", "system"537 )538 elif isinstance(message, FunctionMessage):539 message_dict["role"] = "function"540 elif isinstance(message, ToolMessage):541 message_dict["role"] = "tool"542 message_dict["tool_call_id"] = message.tool_call_id543 message_dict["content"] = _sanitize_chat_completions_content(544 message_dict["content"]545 )546 supported_props = {"content", "role", "tool_call_id"}547 message_dict = {k: v for k, v in message_dict.items() if k in supported_props}548 else:549 msg = f"Got unknown type {message}"550 raise TypeError(msg)551 return message_dict552553554def _convert_delta_to_message_chunk(555 _dict: Mapping[str, Any], default_class: type[BaseMessageChunk]556) -> BaseMessageChunk:557 """Convert to a LangChain message chunk."""558 id_ = _dict.get("id")559 role = cast(str, _dict.get("role"))560 content = cast(str, _dict.get("content") or "")561 additional_kwargs: dict = {}562 if _dict.get("function_call"):563 function_call = dict(_dict["function_call"])564 if "name" in function_call and function_call["name"] is None:565 function_call["name"] = ""566 additional_kwargs["function_call"] = function_call567 tool_call_chunks = []568 if raw_tool_calls := _dict.get("tool_calls"):569 try:570 tool_call_chunks = [571 tool_call_chunk(572 name=rtc["function"].get("name"),573 args=rtc["function"].get("arguments"),574 id=rtc.get("id"),575 index=rtc["index"],576 )577 for rtc in raw_tool_calls578 ]579 except KeyError:580 pass581582 if role == "user" or default_class == HumanMessageChunk:583 return HumanMessageChunk(content=content, id=id_)584 if role == "assistant" or default_class == AIMessageChunk:585 return AIMessageChunk(586 content=content,587 additional_kwargs=additional_kwargs,588 id=id_,589 tool_call_chunks=tool_call_chunks, # type: ignore[arg-type]590 )591 if role in ("system", "developer") or default_class == SystemMessageChunk:592 if role == "developer":593 additional_kwargs = {"__openai_role__": "developer"}594 else:595 additional_kwargs = {}596 return SystemMessageChunk(597 content=content, id=id_, additional_kwargs=additional_kwargs598 )599 if role == "function" or default_class == FunctionMessageChunk:600 return FunctionMessageChunk(content=content, name=_dict["name"], id=id_)601 if role == "tool" or default_class == ToolMessageChunk:602 return ToolMessageChunk(603 content=content, tool_call_id=_dict["tool_call_id"], id=id_604 )605 if role or default_class == ChatMessageChunk:606 return ChatMessageChunk(content=content, role=role, id=id_)607 return default_class(content=content, id=id_) # type: ignore[call-arg]608609610def _update_token_usage(611 overall_token_usage: int | dict, new_usage: int | dict612) -> int | dict:613 # Token usage is either ints or dictionaries614 # `reasoning_tokens` is nested inside `completion_tokens_details`615 if isinstance(new_usage, int):616 if not isinstance(overall_token_usage, int):617 msg = (618 f"Got different types for token usage: "619 f"{type(new_usage)} and {type(overall_token_usage)}"620 )621 raise ValueError(msg)622 return new_usage + overall_token_usage623 if isinstance(new_usage, dict):624 if not isinstance(overall_token_usage, dict):625 msg = (626 f"Got different types for token usage: "627 f"{type(new_usage)} and {type(overall_token_usage)}"628 )629 raise ValueError(msg)630 return {631 k: _update_token_usage(overall_token_usage.get(k, 0), v)632 for k, v in new_usage.items()633 }634 warnings.warn(f"Unexpected type for token usage: {type(new_usage)}")635 return new_usage636637638class OpenAIContextOverflowError(openai.BadRequestError, ContextOverflowError):639 """BadRequestError raised when input exceeds OpenAI's context limit."""640641642class OpenAIAPIContextOverflowError(openai.APIError, ContextOverflowError):643 """APIError raised when input exceeds OpenAI's context limit."""644645646class OpenAIAuthenticationError(openai.AuthenticationError, ModelAuthenticationError):647 """OpenAI authentication error classified as a LangChain model error."""648649650class OpenAIPermissionDeniedError(651 openai.PermissionDeniedError, ModelPermissionDeniedError652):653 """OpenAI permission error classified as a LangChain model error."""654655656class OpenAIInvalidRequestError(openai.BadRequestError, ModelInvalidRequestError):657 """OpenAI bad-request error classified as a LangChain model error."""658659660class OpenAIModelNotFoundError(openai.NotFoundError, ModelNotFoundError):661 """OpenAI not-found error classified as a LangChain model error."""662663664class OpenAIRateLimitError(openai.RateLimitError, ModelRateLimitError):665 """OpenAI rate-limit error classified as a LangChain model error."""666667668class OpenAIAPIError(openai.InternalServerError, ModelAPIError):669 """OpenAI server error classified as a LangChain model error."""670671672class OpenAIConnectionError(openai.APIConnectionError, ModelConnectionError):673 """OpenAI connection error classified as a LangChain model error."""674675676class OpenAITimeoutError(openai.APITimeoutError, ModelTimeoutError):677 """OpenAI timeout error classified as a LangChain model error."""678679680def _handle_openai_bad_request(e: openai.BadRequestError) -> None:681 if (682 "context_length_exceeded" in str(e)683 or "Input tokens exceed the configured limit" in e.message684 or "prompt is too long" in e.message685 or "ContextWindowExceededError" in e.message686 ):687 raise OpenAIContextOverflowError(688 message=e.message, response=e.response, body=e.body689 ) from e690 if (691 "'response_format' of type 'json_schema' is not supported with this model"692 ) in e.message:693 message = (694 "This model does not support OpenAI's structured output feature, which "695 "is the default method for `with_structured_output` as of "696 "langchain-openai==0.3. To use `with_structured_output` with this model, "697 'specify `method="function_calling"`.'698 )699 warnings.warn(message)700 raise OpenAIInvalidRequestError(701 message=e.message, response=e.response, body=e.body702 ) from e703 if "Invalid schema for response_format" in e.message:704 message = (705 "Invalid schema for OpenAI's structured output feature, which is the "706 "default method for `with_structured_output` as of langchain-openai==0.3. "707 'Specify `method="function_calling"` instead or update your schema. '708 "See supported schemas: "709 "https://platform.openai.com/docs/guides/structured-outputs#supported-schemas"710 )711 warnings.warn(message)712 raise OpenAIInvalidRequestError(713 message=e.message, response=e.response, body=e.body714 ) from e715716717def _handle_openai_api_error(e: openai.APIError) -> None:718 error_message = str(e)719 if "exceeds the context window" in error_message:720 raise OpenAIAPIContextOverflowError(721 message=e.message, request=e.request, body=e.body722 ) from e723 if isinstance(e, openai.AuthenticationError):724 raise OpenAIAuthenticationError(725 message=e.message, response=e.response, body=e.body726 ) from e727 if isinstance(e, openai.PermissionDeniedError):728 raise OpenAIPermissionDeniedError(729 message=e.message, response=e.response, body=e.body730 ) from e731 if isinstance(e, openai.NotFoundError):732 raise OpenAIModelNotFoundError(733 message=e.message, response=e.response, body=e.body734 ) from e735 if isinstance(e, openai.RateLimitError):736 raise OpenAIRateLimitError(737 message=e.message, response=e.response, body=e.body738 ) from e739 if isinstance(e, openai.InternalServerError):740 raise OpenAIAPIError(message=e.message, response=e.response, body=e.body) from e741 if isinstance(e, openai.APITimeoutError):742 raise OpenAITimeoutError(e.request) from e743 if isinstance(e, openai.APIConnectionError):744 raise OpenAIConnectionError(message=e.message, request=e.request) from e745 raise746747748def _add_gateway_metadata(generation_info: dict[str, Any], raw_response: Any) -> None:749 """Add parsed LangSmith gateway metadata to `generation_info`, if present.750751 Args:752 generation_info: Generation info to mutate in place.753 raw_response: The raw provider response, or None.754 """755 headers = getattr(raw_response, "headers", None)756 if headers is None:757 return758 gateway_metadata = _parse_gateway_metadata(headers)759 if gateway_metadata is not None:760 generation_info[GATEWAY_METADATA_RESPONSE_KEY] = gateway_metadata761762763_RESPONSES_API_ONLY_PREFIXES = (764 "gpt-5-pro",765 "gpt-5.2-pro",766 "gpt-5.4-pro",767 "gpt-5.5-pro",768 "gpt-5.6-sol",769)770771772def _model_prefers_responses_api(model_name: str | None) -> bool:773 if not model_name:774 return False775 return model_name.startswith(_RESPONSES_API_ONLY_PREFIXES) or "codex" in model_name776777778_BM = TypeVar("_BM", bound=BaseModel)779_DictOrPydanticClass: TypeAlias = dict[str, Any] | type[_BM] | type780_DictOrPydantic: TypeAlias = dict | _BM781782783class BaseChatOpenAI(BaseChatModel):784 """Base wrapper around OpenAI large language models for chat.785786 This base class targets787 [official OpenAI API specifications](https://github.com/openai/openai-openapi)788 only. Non-standard response fields added by third-party providers (e.g.,789 `reasoning_content`) are not extracted. Use a provider-specific subclass for790 full provider support.791 """792793 client: Any = Field(default=None, exclude=True)794795 async_client: Any = Field(default=None, exclude=True)796797 root_client: Any = Field(default=None, exclude=True)798799 root_async_client: Any = Field(default=None, exclude=True)800801 model_name: str = Field(default="gpt-3.5-turbo", alias="model")802 """Model name to use."""803804 temperature: float | None = None805 """What sampling temperature to use."""806807 model_kwargs: dict[str, Any] = Field(default_factory=dict)808 """Holds any model parameters valid for `create` call not explicitly specified."""809810 openai_api_key: (811 SecretStr | None | Callable[[], str] | Callable[[], Awaitable[str]]812 ) = Field(alias="api_key", default=None)813 """API key to use.814815 Can be inferred from the `OPENAI_API_KEY` environment variable, or specified816 as a string, or sync or async callable that returns a string.817818 ??? example "Specify with environment variable"819820 ```bash821 export OPENAI_API_KEY=...822 ```823 ```python824 from langchain_openai import ChatOpenAI825826 model = ChatOpenAI(model="gpt-5-nano")827 ```828829 ??? example "Specify with a string"830831 ```python832 from langchain_openai import ChatOpenAI833834 model = ChatOpenAI(model="gpt-5-nano", api_key="...")835 ```836837 ??? example "Specify with a sync callable"838839 ```python840 from langchain_openai import ChatOpenAI841842 def get_api_key() -> str:843 # Custom logic to retrieve API key844 return "..."845846 model = ChatOpenAI(model="gpt-5-nano", api_key=get_api_key)847 ```848849 ??? example "Specify with an async callable"850851 ```python852 from langchain_openai import ChatOpenAI853854 async def get_api_key() -> str:855 # Custom async logic to retrieve API key856 return "..."857858 model = ChatOpenAI(model="gpt-5-nano", api_key=get_api_key)859 ```860 """861862 openai_api_base: str | None = Field(default=None, alias="base_url")863 """Base URL path for API requests, leave blank if not using a proxy or service emulator.864865 Resolution order (first match wins):866867 1. Explicit `base_url` (or `openai_api_base`) kwarg.868 2. Env var `OPENAI_API_BASE` (read by LangChain at init).869 3. Env var `OPENAI_BASE_URL` (read by the underlying `openai` SDK client).870871 `OPENAI_BASE_URL` is also inspected by LangChain only to decide whether to872 default-enable `stream_usage` — when set, the default is left off because many873 non-OpenAI endpoints do not support streaming token usage.874 """ # noqa: E501875876 openai_organization: str | None = Field(default=None, alias="organization")877 """Automatically inferred from env var `OPENAI_ORG_ID` if not provided."""878879 # to support explicit proxy for OpenAI880 openai_proxy: str | None = Field(881 default_factory=from_env("OPENAI_PROXY", default=None)882 )883884 request_timeout: float | tuple[float, float] | Any | None = Field(885 default=None, alias="timeout"886 )887 """Timeout for requests to OpenAI completion API.888889 Can be float, `httpx.Timeout` or `None`.890 """891892 stream_usage: bool | None = None893 """Whether to include usage metadata in streaming output.894895 If enabled, an additional message chunk will be generated during the stream896 including usage metadata.897898 This parameter is enabled unless `openai_api_base` is set or the model is899 initialized with a custom client, as many chat completions APIs do not900 support streaming token usage.901902 !!! version-added "Added in `langchain-openai` 0.3.9"903904 !!! warning "Behavior changed in `langchain-openai` 0.3.35"905906 Enabled for default base URL and client.907 """908909 max_retries: int | None = None910 """Maximum number of retries to make when generating."""911912 presence_penalty: float | None = None913 """Penalizes repeated tokens."""914915 frequency_penalty: float | None = None916 """Penalizes repeated tokens according to frequency."""917918 seed: int | None = None919 """Seed for generation"""920921 logprobs: bool | None = None922 """Whether to return logprobs."""923924 top_logprobs: int | None = None925 """Number of most likely tokens to return at each token position, each with an926 associated log probability.927928 `logprobs` must be set to true if this parameter is used.929 """930931 logit_bias: dict[int, int] | None = None932 """Modify the likelihood of specified tokens appearing in the completion."""933934 streaming: bool = False935 """Whether to stream the results or not."""936937 n: int | None = None938 """Number of chat completions to generate for each prompt."""939940 top_p: float | None = None941 """Total probability mass of tokens to consider at each step."""942943 max_tokens: int | None = Field(default=None)944 """Maximum number of tokens to generate."""945946 reasoning_effort: str | None = None947 """Constrains effort on reasoning for reasoning models.948949 For use with the Chat Completions API. Reasoning models only.950951 Currently supported values are `'minimal'`, `'low'`, `'medium'`, and952 `'high'`. Reducing reasoning effort can result in faster responses and fewer953 tokens used on reasoning in a response.954955 !!! note "Changing reasoning effort mid-conversation"956957 Changing this value part-way through a conversation changes a request-level958 parameter, which invalidates the cached prompt prefix.959960 Models that support it (currently GPT-6) can instead carry the new effort961 in a `configuration_update` item attached to the message that should start962 using it:963964 ```python965 HumanMessage(966 [967 {"type": "configuration_update", "reasoning": {"effort": "high"}},968 {"type": "text", "text": "Analyze the failure modes."},969 ]970 )971 ```972973 The new effort applies from that message onward, until another update974 overrides it.975 """976977 reasoning: dict[str, Any] | None = None978 """Reasoning parameters for reasoning models. None disables reasoning.979980 For use with the Responses API.981982 ```python983 reasoning={984 "effort": None, # Default None; can be "low", "medium", or "high"985 "summary": "auto", # Can be "auto", "concise", or "detailed"986 }987 ```988989 !!! version-added "Added in `langchain-openai` 0.3.24"990 """991992 verbosity: str | None = None993 """Controls the verbosity level of responses for reasoning models.994995 For use with the Responses API.996997 Currently supported values are `'low'`, `'medium'`, and `'high'`.998999 !!! version-added "Added in `langchain-openai` 0.3.28"1000 """10011002 tiktoken_model_name: str | None = None1003 """The model name to pass to tiktoken when using this class.10041005 Tiktoken is used to count the number of tokens in documents to constrain1006 them to be under a certain limit.10071008 By default, when set to `None`, this will be the same as the embedding model name.1009 However, there are some cases where you may want to use this `Embedding` class with1010 a model name not supported by tiktoken. This can include when using Azure embeddings1011 or when using one of the many model providers that expose an OpenAI-like1012 API but with different models. In those cases, in order to avoid erroring1013 when tiktoken is called, you can specify a model name to use here.1014 """10151016 default_headers: Mapping[str, str] | None = None10171018 default_query: Mapping[str, object] | None = None10191020 # Configure a custom httpx client. See the1021 # [httpx documentation](https://www.python-httpx.org/api/#client) for more details.1022 http_client: Any | None = Field(default=None, exclude=True)1023 """Optional `httpx.Client`.10241025 Only used for sync invocations. Must specify `http_async_client` as well if1026 you'd like a custom client for async invocations.1027 """10281029 http_async_client: Any | None = Field(default=None, exclude=True)1030 """Optional `httpx.AsyncClient`.10311032 Only used for async invocations. Must specify `http_client` as well if you'd1033 like a custom client for sync invocations.1034 """10351036 http_socket_options: Sequence[tuple[int, int, int]] | None = Field(1037 default=None, exclude=True1038 )1039 """TCP socket options applied to the httpx transports built by this instance.10401041 Defaults to a conservative TCP-keepalive + `TCP_USER_TIMEOUT` profile that1042 targets a ~2-minute bound on silent connection hangs (silent mid-stream peer1043 loss, gVisor/NAT idle timeouts, silent TCP black holes) on platforms that1044 support the full option set. On platforms that only support a subset1045 (macOS without `TCP_USER_TIMEOUT`, Windows with only `SO_KEEPALIVE`,1046 minimal kernels), unsupported options are silently dropped and the bound1047 degrades to whatever the remaining options + OS defaults provide — still1048 better than indefinite hang.10491050 Accepted values:10511052 - `None` (default): use env-driven defaults. Matches the "unset" convention1053 used by `http_client` elsewhere on this class.1054 - `()` (empty): disable socket-option injection entirely. Inherits the OS1055 defaults and restores httpx's native env-proxy auto-detection.1056 - A non-empty sequence of `(level, option, value)` tuples: explicit1057 override; passed verbatim to the transport (not filtered). Unsupported1058 options raise `OSError` at connect time rather than being silently1059 dropped — the user chose them explicitly.10601061 Environment variables (only consulted when this field is `None`):1062 `LANGCHAIN_OPENAI_TCP_KEEPALIVE` (set to `0` to disable entirely — the1063 kill-switch), `LANGCHAIN_OPENAI_TCP_KEEPIDLE`,1064 `LANGCHAIN_OPENAI_TCP_KEEPINTVL`, `LANGCHAIN_OPENAI_TCP_KEEPCNT`,1065 `LANGCHAIN_OPENAI_TCP_USER_TIMEOUT_MS`.10661067 Applied per side: if `http_client` is supplied, the sync path uses1068 that user-owned client's socket options as-is; the async path still1069 gets `http_socket_options` applied to its default builder (and1070 vice-versa for `http_async_client`). Supply both to take full control.10711072 !!! note "Interaction with env-proxy auto-detection"10731074 When a custom `httpx` transport is active, `httpx` disables its1075 native env-proxy auto-detection (`HTTP_PROXY` / `HTTPS_PROXY` /1076 `ALL_PROXY` / `NO_PROXY` and macOS/Windows system proxy settings).10771078 To keep the default shape safe, `ChatOpenAI` detects the1079 "proxy-env-shadow" pattern and **skips the custom transport1080 entirely** when **all** of the following hold:10811082 - `http_socket_options` is left at its default (`None`)1083 - No `http_client` or `http_async_client` supplied1084 - No `openai_proxy` supplied1085 - A proxy env var or system proxy is visible to httpx10861087 On that specific shape, the instance falls back to pre-PR behavior1088 and httpx's env-proxy auto-detection applies (a one-time `INFO` log1089 records the bypass for observability).10901091 If you explicitly set `http_socket_options=[...]` while a proxy1092 env var is also set, no bypass — you opted into the transport, and1093 a one-time `WARNING` records the shadowing. Set1094 `http_socket_options=()` or `LANGCHAIN_OPENAI_TCP_KEEPALIVE=0` to1095 disable transport injection explicitly, or pass a fully-configured1096 `http_async_client` / `http_client` to take full control. The1097 `openai_proxy` constructor kwarg is unaffected — socket options1098 are applied cleanly through the proxied transport on that path.1099 """11001101 stream_chunk_timeout: float | None = Field(1102 default_factory=lambda: _float_env(1103 "LANGCHAIN_OPENAI_STREAM_CHUNK_TIMEOUT_S", 120.01104 ),1105 exclude=True,1106 )1107 """Per-chunk wall-clock timeout (seconds) on async streaming responses.11081109 Applies to async invocations only (`astream`, `ainvoke` with streaming,1110 etc.). Sync streaming (`stream`) is not affected.11111112 Fires between content chunks yielded by the openai SDK's streaming iterator1113 (i.e., each call to `__anext__` on the response). Crucially, this is1114 **not** the same as httpx's `timeout.read`:11151116 - httpx's read timeout is inter-byte and gets reset every time *any* bytes1117 arrive on the socket — including OpenAI's SSE keepalive comments1118 (`: keepalive`) that trickle down during long model generations. A1119 stream that's silent on *content* but still producing keepalives looks1120 alive forever to httpx.1121 - `stream_chunk_timeout` measures the gap between *parsed chunks*. The1122 openai SDK's SSE parser consumes keepalive comments internally and does1123 not emit them as chunks, so keepalives do *not* reset this timer. It1124 fires on genuine content silence.11251126 When it fires, a `StreamChunkTimeoutError`1127 (subclass of `asyncio.TimeoutError`) is raised with a self-describing1128 message naming this knob, the env-var override, the model, and the1129 number of chunks received before the stall. A WARNING log with1130 `extra={"source": "stream_chunk_timeout", "timeout_s": <value>,1131 "model_name": <value>, "chunks_received": <value>}` also fires so1132 aggregate logging can distinguish app-layer timeouts from1133 transport-layer failures.11341135 Defaults to 120s. Set to `None` or `0` to disable. Overridable via the1136 `LANGCHAIN_OPENAI_STREAM_CHUNK_TIMEOUT_S` env var. Negative values1137 (from either the env var or the constructor kwarg — e.g., hydrated1138 from YAML/JSON configs) fall back to the default with a `WARNING` log1139 rather than silently disabling the wrapper, so a misconfigured value1140 still boots safely and the fallback is visible.1141 """11421143 stop: list[str] | str | None = Field(default=None, alias="stop_sequences")1144 """Default stop sequences."""11451146 extra_body: Mapping[str, Any] | None = None1147 """Optional additional JSON properties to include in the request parameters1148 when making requests to OpenAI compatible APIs, such as vLLM, LM Studio, or1149 other providers.11501151 This is the recommended way to pass custom parameters that are specific to your1152 OpenAI-compatible API provider but not part of the standard OpenAI API.11531154 Examples:1155 - [LM Studio](https://lmstudio.ai/) TTL parameter: `extra_body={"ttl": 300}`1156 - [vLLM](https://github.com/vllm-project/vllm) custom parameters:1157 `extra_body={"use_beam_search": True}`1158 - Any other provider-specific parameters11591160 !!! warning11611162 Do not use `model_kwargs` for custom parameters that are not part of the1163 standard OpenAI API, as this will cause errors when making API calls. Use1164 `extra_body` instead.1165 """11661167 include_response_headers: bool = False1168 """Whether to include response headers in the output message `response_metadata`.11691170 Note: some inference providers return additional metadata (such as served model1171 names) in the response headers. Enable to capture these metadata.1172 """11731174 disabled_params: dict[str, Any] | None = Field(default=None)1175 """Parameters of the OpenAI client or `chat.completions` endpoint that should be1176 disabled for the given model.11771178 Should be specified as `{"param": None | ['val1', 'val2']}` where the key is the1179 parameter and the value is either None, meaning that parameter should never be1180 used, or it's a list of disabled values for the parameter.11811182 For example, older models may not support the `'parallel_tool_calls'` parameter at1183 all, in which case `disabled_params={"parallel_tool_calls": None}` can be passed1184 in.11851186 If a parameter is disabled then it will not be used by default in any methods, e.g.1187 in `with_structured_output`. However this does not prevent a user from directly1188 passed in the parameter during invocation.1189 """11901191 context_management: list[dict[str, Any]] | None = None1192 """Configuration for1193 [context management](https://developers.openai.com/api/docs/guides/compaction).1194 """11951196 include: list[str] | None = None1197 """Additional fields to include in generations from Responses API.11981199 Supported values:12001201 - `'file_search_call.results'`1202 - `'message.input_image.image_url'`1203 - `'computer_call_output.output.image_url'`1204 - `'reasoning.encrypted_content'`1205 - `'code_interpreter_call.outputs'`12061207 !!! version-added "Added in `langchain-openai` 0.3.24"1208 """12091210 prompt_cache_options: dict[str, Any] | None = None1211 """Options controlling OpenAI prompt cache behavior.12121213 !!! version-added "Added in `langchain-openai` 1.3.5"1214 """12151216 service_tier: str | None = None1217 """Latency tier for request.12181219 Options are `'auto'`, `'default'`, or `'flex'`.12201221 Relevant for users of OpenAI's scale tier service.1222 """12231224 store: bool | None = None1225 """If `True`, OpenAI may store response data for future use.12261227 Defaults to `True` for the Responses API and `False` for the Chat Completions API.12281229 !!! version-added "Added in `langchain-openai` 0.3.24"1230 """12311232 truncation: str | None = None1233 """Truncation strategy (Responses API).12341235 Can be `'auto'` or `'disabled'` (default).12361237 If `'auto'`, model may drop input items from the middle of the message sequence to1238 fit the context window.12391240 !!! version-added "Added in `langchain-openai` 0.3.24"1241 """12421243 use_previous_response_id: bool = False1244 """If `True`, always pass `previous_response_id` using the ID of the most recent1245 response. Responses API only.12461247 Input messages up to the most recent response will be dropped from request1248 payloads.12491250 For example, the following two are equivalent:12511252 ```python1253 model = ChatOpenAI(1254 model="...",1255 use_previous_response_id=True,1256 )1257 model.invoke(1258 [1259 HumanMessage("Hello"),1260 AIMessage("Hi there!", response_metadata={"id": "resp_123"}),1261 HumanMessage("How are you?"),1262 ]1263 )1264 ```12651266 ```python1267 model = ChatOpenAI(model="...", use_responses_api=True)1268 model.invoke([HumanMessage("How are you?")], previous_response_id="resp_123")1269 ```12701271 !!! version-added "Added in `langchain-openai` 0.3.26"1272 """12731274 use_responses_api: bool | None = None1275 """Whether to use the Responses API instead of the Chat API.12761277 If not specified, set to `True` when instance settings require the Responses API,12781279 !!! version-added "Added in `langchain-openai` 0.3.9"1280 """12811282 output_version: str | None = Field(1283 default_factory=from_env("LC_OUTPUT_VERSION", default=None)1284 )1285 """Version of `AIMessage` output format to use.12861287 This field is used to roll-out new output formats for chat model `AIMessage`1288 responses in a backwards-compatible way.12891290 Supported values:12911292 - `'v0'`: `AIMessage` format as of `langchain-openai 0.3.x`.1293 - `'responses/v1'`: Formats Responses API output items into AIMessage content blocks1294 (Responses API only)1295 - `'v1'`: v1 of LangChain cross-provider standard.12961297 !!! warning "Behavior changed in `langchain-openai` 1.0.0"12981299 Default updated to `"responses/v1"`.1300 """13011302 model_config = ConfigDict(populate_by_name=True)13031304 @property1305 def _uses_gateway(self) -> bool:1306 """Whether requests are routed through the LangSmith gateway.13071308 Detected from the resolved API key: LangSmith keys (used to authenticate1309 to the gateway) carry the `lsv2_` prefix. Callable keys cannot be1310 inspected without invoking them, so they are treated as non-gateway.1311 """1312 api_key = self.openai_api_key1313 if isinstance(api_key, SecretStr):1314 return api_key.get_secret_value().startswith("lsv2_")1315 return False13161317 @property1318 def model(self) -> str:1319 """Same as model_name."""1320 return self.model_name13211322 @model_validator(mode="before")1323 @classmethod1324 def build_extra(cls, values: dict[str, Any]) -> Any:1325 """Build extra kwargs from additional params that were passed in."""1326 all_required_field_names = get_pydantic_field_names(cls)1327 return _build_model_kwargs(values, all_required_field_names)13281329 @field_validator("stream_chunk_timeout", mode="after")1330 @classmethod1331 def _validate_stream_chunk_timeout(cls, value: float | None) -> float | None:1332 """Reject negative constructor values; fall back to the env-driven default.13331334 Matches the env-var path in `_float_env`: a negative value is a typo,1335 not an opt-out (`None`/`0` are the documented off switches). Configs1336 hydrated from YAML/JSON would otherwise silently disable the wrapper1337 and reintroduce the indefinite-stream hang the feature prevents.1338 """1339 if value is not None and value < 0:1340 fallback = _float_env("LANGCHAIN_OPENAI_STREAM_CHUNK_TIMEOUT_S", 120.0)1341 logger.warning(1342 "Invalid `stream_chunk_timeout=%r` (negative); "1343 "falling back to %s. Pass `None` or `0` to disable.",1344 value,1345 fallback,1346 )1347 return fallback1348 return value13491350 @model_validator(mode="before")1351 @classmethod1352 def validate_temperature(cls, values: dict[str, Any]) -> Any:1353 """Validate temperature parameter for different models.13541355 - gpt-5 models (excluding gpt-5-chat) only allow `temperature=1` or unset1356 (Defaults to 1)1357 """1358 model = values.get("model_name") or values.get("model") or ""1359 model_lower = model.lower()13601361 # For o1 models, set temperature=1 if not provided1362 if model_lower.startswith("o1") and "temperature" not in values:1363 values["temperature"] = 113641365 # For gpt-5 models, handle temperature restrictions. Temperature is supported1366 # by gpt-5-chat and gpt-5 models with reasoning_effort='none' or1367 # reasoning={'effort': 'none'}.1368 if (1369 model_lower.startswith("gpt-5")1370 and ("chat" not in model_lower)1371 and values.get("reasoning_effort") != "none"1372 and (values.get("reasoning") or {}).get("effort") != "none"1373 ):1374 temperature = values.get("temperature")1375 if temperature is not None and temperature != 1:1376 # For gpt-5 (non-chat), only temperature=1 is supported1377 # So we remove any non-defaults1378 values.pop("temperature", None)13791380 return values13811382 @model_validator(mode="after")1383 def _set_openai_chat_version(self) -> Self:1384 """Set package version in metadata.13851386 Note: Subclasses that inherit from `BaseChatOpenAI` (e.g.1387 `ChatDeepSeek`, `ChatXAI`) must use a **unique** validator name1388 (e.g. `_set_deepseek_version`) instead of overriding this one. Pydantic1389 replaces same-named `model_validator` methods rather than chaining them,1390 so reusing `_set_openai_chat_version` would silently drop the parent's1391 `langchain-openai` version entry.1392 """1393 self._add_version("langchain-openai", __version__)1394 return self13951396 @model_validator(mode="after")1397 def _infer_use_responses_api(self) -> Self:1398 """Expose unconditional instance-level Responses API routing."""1399 if self.use_responses_api is None and self._use_responses_api({}):1400 self.use_responses_api = True1401 return self14021403 @model_validator(mode="after")1404 def validate_environment(self) -> Self:1405 """Validate that api key and python package exists in environment."""1406 if self.n is not None and self.n < 1:1407 msg = "n must be at least 1."1408 raise ValueError(msg)1409 if self.n is not None and self.n > 1 and self.streaming:1410 msg = "n must be 1 when streaming."1411 raise ValueError(msg)14121413 # Check OPENAI_ORGANIZATION for backwards compatibility.1414 self.openai_organization = (1415 self.openai_organization1416 or os.getenv("OPENAI_ORG_ID")1417 or os.getenv("OPENAI_ORGANIZATION")1418 )1419 # Resolve base URL and API key, applying LangSmith gateway settings.1420 _gateway_config = _resolve_gateway_config(1421 base_url=self.openai_api_base,1422 api_key=self.openai_api_key,1423 provider_path="openai/v1",1424 base_url_env="OPENAI_API_BASE",1425 api_key_env="OPENAI_API_KEY",1426 )1427 self.openai_api_base = _gateway_config.base_url1428 self.openai_api_key = _gateway_config.api_key1429 _base_url_from_gateway = _gateway_config.base_url_from_gateway14301431 # Enable stream_usage by default if using default base URL and client,1432 # or when the base URL was set by the LangSmith gateway (which proxies1433 # to OpenAI and supports streaming token usage).1434 if all(1435 getattr(self, key, None) is None1436 for key in (1437 "stream_usage",1438 "openai_proxy",1439 "client",1440 "root_client",1441 "async_client",1442 "root_async_client",1443 "http_client",1444 "http_async_client",1445 )1446 ) and (1447 _base_url_from_gateway1448 or (self.openai_api_base is None and "OPENAI_BASE_URL" not in os.environ)1449 ):1450 self.stream_usage = True14511452 # Resolve API key from SecretStr or Callable1453 sync_api_key_value: str | Callable[[], str] | None = None1454 async_api_key_value: str | Callable[[], Awaitable[str]] | None = None14551456 if self.openai_api_key is not None:1457 # Because OpenAI and AsyncOpenAI clients support either sync or async1458 # callables for the API key, we need to resolve separate values here.1459 sync_api_key_value, async_api_key_value = _resolve_sync_and_async_api_keys(1460 self.openai_api_key1461 )14621463 client_params: dict = {1464 "organization": self.openai_organization,1465 "base_url": self.openai_api_base,1466 "timeout": self.request_timeout,1467 "default_headers": self.default_headers,1468 "default_query": self.default_query,1469 }1470 if self.max_retries is not None:1471 client_params["max_retries"] = self.max_retries14721473 if self.openai_proxy and (self.http_client or self.http_async_client):1474 openai_proxy = self.openai_proxy1475 http_client = self.http_client1476 http_async_client = self.http_async_client1477 msg = (1478 "Cannot specify 'openai_proxy' if one of "1479 "'http_client'/'http_async_client' is already specified. Received:\n"1480 f"{openai_proxy=}\n{http_client=}\n{http_async_client=}"1481 )1482 raise ValueError(msg)1483 if _should_bypass_socket_options_for_proxy_env(1484 http_socket_options=self.http_socket_options,1485 http_client=self.http_client,1486 http_async_client=self.http_async_client,1487 openai_proxy=self.openai_proxy,1488 ):1489 # Default-shape construction + proxy env var visible to httpx:1490 # skip the custom transport so httpx's env-proxy auto-detection1491 # still applies. Users who want kernel-level TCP tuning alongside1492 # an env proxy can opt in explicitly via `http_socket_options`.1493 resolved_socket_options: tuple[tuple[int, int, int], ...] = ()1494 _log_proxy_env_bypass_once()1495 else:1496 resolved_socket_options = _resolve_socket_options(self.http_socket_options)1497 _warn_if_proxy_env_shadowed(1498 resolved_socket_options, openai_proxy=self.openai_proxy1499 )1500 if not self.client:1501 if sync_api_key_value is None:1502 # No valid sync API key, leave client as None and raise informative1503 # error on invocation.1504 self.client = None1505 self.root_client = None1506 else:1507 if self.openai_proxy and not self.http_client:1508 self.http_client = _build_proxied_sync_httpx_client(1509 proxy=self.openai_proxy,1510 verify=global_ssl_context,1511 socket_options=resolved_socket_options,1512 )1513 sync_specific = {1514 "http_client": self.http_client1515 or _get_default_httpx_client(1516 self.openai_api_base,1517 self.request_timeout,1518 resolved_socket_options,1519 ),1520 "api_key": sync_api_key_value,1521 }1522 self.root_client = openai.OpenAI(**client_params, **sync_specific) # type: ignore[arg-type]1523 self.client = self.root_client.chat.completions1524 if not self.async_client:1525 if self.openai_proxy and not self.http_async_client:1526 self.http_async_client = _build_proxied_async_httpx_client(1527 proxy=self.openai_proxy,1528 verify=global_ssl_context,1529 socket_options=resolved_socket_options,1530 )1531 async_specific = {1532 "http_client": self.http_async_client1533 or _get_default_async_httpx_client(1534 self.openai_api_base,1535 self.request_timeout,1536 resolved_socket_options,1537 ),1538 "api_key": async_api_key_value,1539 }1540 self.root_async_client = openai.AsyncOpenAI(1541 **client_params,1542 **async_specific, # type: ignore[arg-type]1543 )1544 self.async_client = self.root_async_client.chat.completions1545 return self15461547 def _resolve_model_profile(self) -> ModelProfile | None:1548 profile = _get_default_model_profile(self.model_name) or None1549 if (1550 profile is not None1551 and profile.get("mid_conversation_tools")1552 and not self._use_responses_api({})1553 ):1554 # Only the Responses API carries an `additional_tools` input item.1555 # Routing that depends on the request can't be claimed up front.1556 profile["mid_conversation_tools"] = False1557 return profile15581559 @property1560 def _default_params(self) -> dict[str, Any]:1561 """Get the default parameters for calling OpenAI API."""1562 exclude_if_none = {1563 "presence_penalty": self.presence_penalty,1564 "frequency_penalty": self.frequency_penalty,1565 "seed": self.seed,1566 "top_p": self.top_p,1567 "logprobs": self.logprobs,1568 "top_logprobs": self.top_logprobs,1569 "logit_bias": self.logit_bias,1570 "stop": self.stop or None, # Also exclude empty list for this1571 "max_tokens": self.max_tokens,1572 "extra_body": self.extra_body,1573 "n": self.n,1574 "temperature": self.temperature,1575 "reasoning_effort": self.reasoning_effort,1576 "reasoning": self.reasoning,1577 "verbosity": self.verbosity,1578 "context_management": self.context_management,1579 "include": self.include,1580 "prompt_cache_options": self.prompt_cache_options,1581 "service_tier": self.service_tier,1582 "truncation": self.truncation,1583 "store": self.store,1584 }15851586 return {1587 "model": self.model_name,1588 "stream": self.streaming,1589 **{k: v for k, v in exclude_if_none.items() if v is not None},1590 **self.model_kwargs,1591 }15921593 def _combine_llm_outputs(self, llm_outputs: list[dict | None]) -> dict:1594 overall_token_usage: dict = {}1595 system_fingerprint = None1596 for output in llm_outputs:1597 if output is None:1598 # Happens in streaming1599 continue1600 token_usage = output.get("token_usage")1601 if token_usage is not None:1602 for k, v in token_usage.items():1603 if v is None:1604 continue1605 if k in overall_token_usage:1606 overall_token_usage[k] = _update_token_usage(1607 overall_token_usage[k], v1608 )1609 else:1610 overall_token_usage[k] = v1611 if system_fingerprint is None:1612 system_fingerprint = output.get("system_fingerprint")1613 combined = {"token_usage": overall_token_usage, "model_name": self.model_name}1614 if system_fingerprint:1615 combined["system_fingerprint"] = system_fingerprint1616 return combined16171618 def _convert_chunk_to_generation_chunk(1619 self,1620 chunk: dict,1621 default_chunk_class: type,1622 base_generation_info: dict | None,1623 ) -> ChatGenerationChunk | None:1624 if chunk.get("type") == "content.delta": # From beta.chat.completions.stream1625 return None1626 token_usage = chunk.get("usage")1627 choices = (1628 chunk.get("choices", [])1629 # From beta.chat.completions.stream1630 or chunk.get("chunk", {}).get("choices", [])1631 )16321633 usage_metadata: UsageMetadata | None = (1634 _create_usage_metadata(token_usage, chunk.get("service_tier"))1635 if token_usage1636 else None1637 )1638 if len(choices) == 0:1639 # logprobs is implicitly None1640 generation_chunk = ChatGenerationChunk(1641 message=default_chunk_class(content="", usage_metadata=usage_metadata),1642 generation_info=base_generation_info,1643 )1644 # Keep content as "" (the default) rather than converting to [].1645 # Chat Completions content deltas are normalized to strings in1646 # _convert_delta_to_message_chunk. Starting with [] causes1647 # merge_content to silently drop string content (empty list is1648 # falsy, so no merge branch applies). The empty list also triggers1649 # the content_blocks isinstance(list) short-circuit, which would1650 # return [] and miss tool_call_chunks.1651 if self.output_version == "v1":1652 generation_chunk.message.response_metadata["output_version"] = "v1"16531654 return generation_chunk16551656 choice = choices[0]1657 if choice["delta"] is None:1658 return None16591660 message_chunk = _convert_delta_to_message_chunk(1661 choice["delta"], default_chunk_class1662 )1663 generation_info = {**base_generation_info} if base_generation_info else {}16641665 if finish_reason := choice.get("finish_reason"):1666 generation_info["finish_reason"] = finish_reason1667 if model_name := chunk.get("model"):1668 generation_info["model_name"] = model_name1669 if system_fingerprint := chunk.get("system_fingerprint"):1670 generation_info["system_fingerprint"] = system_fingerprint1671 if service_tier := chunk.get("service_tier"):1672 generation_info["service_tier"] = service_tier16731674 logprobs = choice.get("logprobs")1675 if logprobs:1676 generation_info["logprobs"] = logprobs16771678 if usage_metadata and isinstance(message_chunk, AIMessageChunk):1679 message_chunk.usage_metadata = usage_metadata16801681 message_chunk.response_metadata["model_provider"] = "openai"1682 # Propagate output_version so content_blocks can detect v1 mode.1683 if self.output_version == "v1":1684 message_chunk.response_metadata["output_version"] = "v1"1685 return ChatGenerationChunk(1686 message=message_chunk, generation_info=generation_info or None1687 )16881689 def _ensure_sync_client_available(self) -> None:1690 """Check that sync client is available, raise error if not."""1691 if self.client is None:1692 msg = (1693 "Sync client is not available. This happens when an async callable "1694 "was provided for the API key. Use async methods (ainvoke, astream) "1695 "instead, or provide a string or sync callable for the API key."1696 )1697 raise ValueError(msg)16981699 def _stream_responses(1700 self,1701 messages: list[BaseMessage],1702 stop: list[str] | None = None,1703 run_manager: CallbackManagerForLLMRun | None = None,1704 **kwargs: Any,1705 ) -> Iterator[ChatGenerationChunk]:1706 self._ensure_sync_client_available()1707 kwargs["stream"] = True1708 payload = self._get_request_payload(messages, stop=stop, **kwargs)1709 headers: dict = {}1710 base_generation_info: dict = {}1711 try:1712 if self.include_response_headers or self._uses_gateway:1713 raw_context_manager = (1714 self.root_client.with_raw_response.responses.create(**payload)1715 )1716 context_manager = raw_context_manager.parse()1717 if self.include_response_headers:1718 headers = {"headers": dict(raw_context_manager.headers)}1719 _add_gateway_metadata(base_generation_info, raw_context_manager)1720 else:1721 context_manager = self.root_client.responses.create(**payload)1722 original_schema_obj = kwargs.get("response_format")17231724 with context_manager as response:1725 is_first_chunk = True1726 current_index = -11727 current_output_index = -11728 current_sub_index = -11729 has_reasoning = False1730 item_output_indices: dict[str, int] = {}1731 for chunk in response:1732 metadata = headers if is_first_chunk else {}1733 (1734 current_index,1735 current_output_index,1736 current_sub_index,1737 generation_chunk,1738 ) = _convert_responses_chunk_to_generation_chunk(1739 chunk,1740 current_index,1741 current_output_index,1742 current_sub_index,1743 schema=original_schema_obj,1744 metadata=metadata,1745 has_reasoning=has_reasoning,1746 output_version=self.output_version,1747 item_output_indices=item_output_indices,1748 )1749 if generation_chunk:1750 if is_first_chunk and base_generation_info:1751 generation_chunk.generation_info = {1752 **base_generation_info,1753 **(generation_chunk.generation_info or {}),1754 }1755 if run_manager:1756 run_manager.on_llm_new_token(1757 generation_chunk.text, chunk=generation_chunk1758 )1759 is_first_chunk = False1760 if "reasoning" in generation_chunk.message.additional_kwargs:1761 has_reasoning = True1762 yield generation_chunk1763 except openai.BadRequestError as e:1764 _handle_openai_bad_request(e)1765 except openai.APIError as e:1766 _handle_openai_api_error(e)17671768 async def _astream_responses(1769 self,1770 messages: list[BaseMessage],1771 stop: list[str] | None = None,1772 run_manager: AsyncCallbackManagerForLLMRun | None = None,1773 **kwargs: Any,1774 ) -> AsyncIterator[ChatGenerationChunk]:1775 kwargs["stream"] = True1776 payload = self._get_request_payload(messages, stop=stop, **kwargs)1777 headers: dict = {}1778 base_generation_info: dict = {}1779 try:1780 if self.include_response_headers or self._uses_gateway:1781 raw_context_manager = (1782 await self.root_async_client.with_raw_response.responses.create(1783 **payload1784 )1785 )1786 context_manager = raw_context_manager.parse()1787 if self.include_response_headers:1788 headers = {"headers": dict(raw_context_manager.headers)}1789 _add_gateway_metadata(base_generation_info, raw_context_manager)1790 else:1791 context_manager = await self.root_async_client.responses.create(1792 **payload1793 )1794 original_schema_obj = kwargs.get("response_format")17951796 async with context_manager as response:1797 is_first_chunk = True1798 current_index = -11799 current_output_index = -11800 current_sub_index = -11801 has_reasoning = False1802 item_output_indices: dict[str, int] = {}1803 async for chunk in _astream_with_chunk_timeout(1804 response,1805 self.stream_chunk_timeout,1806 model_name=self.model_name,1807 ):1808 metadata = headers if is_first_chunk else {}1809 (1810 current_index,1811 current_output_index,1812 current_sub_index,1813 generation_chunk,1814 ) = _convert_responses_chunk_to_generation_chunk(1815 chunk,1816 current_index,1817 current_output_index,1818 current_sub_index,1819 schema=original_schema_obj,1820 metadata=metadata,1821 has_reasoning=has_reasoning,1822 output_version=self.output_version,1823 item_output_indices=item_output_indices,1824 )1825 if generation_chunk:1826 if is_first_chunk and base_generation_info:1827 generation_chunk.generation_info = {1828 **base_generation_info,1829 **(generation_chunk.generation_info or {}),1830 }1831 if run_manager:1832 await run_manager.on_llm_new_token(1833 generation_chunk.text, chunk=generation_chunk1834 )1835 is_first_chunk = False1836 if "reasoning" in generation_chunk.message.additional_kwargs:1837 has_reasoning = True1838 yield generation_chunk1839 except openai.BadRequestError as e:1840 _handle_openai_bad_request(e)1841 except openai.APIError as e:1842 _handle_openai_api_error(e)18431844 def _should_stream_usage(1845 self, stream_usage: bool | None = None, **kwargs: Any1846 ) -> bool:1847 """Determine whether to include usage metadata in streaming output.18481849 For backwards compatibility, we check for `stream_options` passed1850 explicitly to kwargs or in the `model_kwargs` and override `self.stream_usage`.1851 """1852 stream_usage_sources = [ # order of precedence1853 stream_usage,1854 kwargs.get("stream_options", {}).get("include_usage"),1855 self.model_kwargs.get("stream_options", {}).get("include_usage"),1856 self.stream_usage,1857 ]1858 for source in stream_usage_sources:1859 if isinstance(source, bool):1860 return source1861 return self.stream_usage or False18621863 def _stream(1864 self,1865 messages: list[BaseMessage],1866 stop: list[str] | None = None,1867 run_manager: CallbackManagerForLLMRun | None = None,1868 *,1869 stream_usage: bool | None = None,1870 **kwargs: Any,1871 ) -> Iterator[ChatGenerationChunk]:1872 self._ensure_sync_client_available()1873 kwargs["stream"] = True1874 stream_usage = self._should_stream_usage(stream_usage, **kwargs)1875 if stream_usage:1876 kwargs["stream_options"] = {"include_usage": stream_usage}1877 payload = self._get_request_payload(messages, stop=stop, **kwargs)1878 default_chunk_class: type[BaseMessageChunk] = AIMessageChunk1879 base_generation_info = {}18801881 try:1882 if "response_format" in payload:1883 if self.include_response_headers:1884 warnings.warn(1885 "Cannot currently include response headers when "1886 "response_format is specified."1887 )1888 payload.pop("stream")1889 response_stream = self.root_client.beta.chat.completions.stream(1890 **payload1891 )1892 context_manager = response_stream1893 else:1894 if self.include_response_headers or self._uses_gateway:1895 raw_response = self.client.with_raw_response.create(**payload)1896 response = raw_response.parse()1897 if self.include_response_headers:1898 base_generation_info = {"headers": dict(raw_response.headers)}1899 _add_gateway_metadata(base_generation_info, raw_response)1900 else:1901 response = self.client.create(**payload)1902 context_manager = response1903 with context_manager as response:1904 is_first_chunk = True1905 for chunk in response:1906 if not isinstance(chunk, dict):1907 chunk = chunk.model_dump()1908 generation_chunk = self._convert_chunk_to_generation_chunk(1909 chunk,1910 default_chunk_class,1911 base_generation_info if is_first_chunk else {},1912 )1913 if generation_chunk is None:1914 continue1915 default_chunk_class = generation_chunk.message.__class__1916 logprobs = (generation_chunk.generation_info or {}).get("logprobs")1917 if run_manager:1918 run_manager.on_llm_new_token(1919 generation_chunk.text,1920 chunk=generation_chunk,1921 logprobs=logprobs,1922 )1923 is_first_chunk = False1924 yield generation_chunk1925 except openai.BadRequestError as e:1926 _handle_openai_bad_request(e)1927 except openai.APIError as e:1928 _handle_openai_api_error(e)1929 if hasattr(response, "get_final_completion") and "response_format" in payload:1930 final_completion = response.get_final_completion()1931 generation_chunk = self._get_generation_chunk_from_completion(1932 final_completion1933 )1934 if run_manager:1935 run_manager.on_llm_new_token(1936 generation_chunk.text, chunk=generation_chunk1937 )1938 yield generation_chunk19391940 def _generate(1941 self,1942 messages: list[BaseMessage],1943 stop: list[str] | None = None,1944 run_manager: CallbackManagerForLLMRun | None = None,1945 **kwargs: Any,1946 ) -> ChatResult:1947 self._ensure_sync_client_available()1948 payload = self._get_request_payload(messages, stop=stop, **kwargs)1949 generation_info = None1950 raw_response = None1951 try:1952 if "response_format" in payload:1953 payload.pop("stream")1954 raw_response = (1955 self.root_client.chat.completions.with_raw_response.parse(**payload)1956 )1957 response = raw_response.parse()1958 elif self._use_responses_api(payload):1959 original_schema_obj = kwargs.get("response_format")1960 if original_schema_obj and _is_pydantic_class(original_schema_obj):1961 raw_response = self.root_client.responses.with_raw_response.parse(1962 **payload1963 )1964 else:1965 raw_response = self.root_client.responses.with_raw_response.create(1966 **payload1967 )1968 response = raw_response.parse()1969 if self.include_response_headers:1970 generation_info = {"headers": dict(raw_response.headers)}1971 generation_info = generation_info or {}1972 _add_gateway_metadata(generation_info, raw_response)1973 # Gateway metadata belongs on `generation_info`, not the message1974 # `response_metadata` that `metadata` populates.1975 gateway_metadata = generation_info.pop(1976 GATEWAY_METADATA_RESPONSE_KEY, None1977 )1978 result = _construct_lc_result_from_responses_api(1979 response,1980 schema=original_schema_obj,1981 metadata=generation_info,1982 output_version=self.output_version,1983 )1984 if gateway_metadata is not None:1985 for generation in result.generations:1986 generation.generation_info = generation.generation_info or {}1987 generation.generation_info[GATEWAY_METADATA_RESPONSE_KEY] = (1988 gateway_metadata1989 )1990 return result1991 else:1992 raw_response = self.client.with_raw_response.create(**payload)1993 response = raw_response.parse()1994 except openai.BadRequestError as e:1995 _handle_openai_bad_request(e)1996 except openai.APIError as e:1997 _handle_openai_api_error(e)1998 except Exception as e:1999 if raw_response is not None and hasattr(raw_response, "http_response"):2000 e.response = raw_response.http_response # type: ignore[attr-defined]
Findings
✓ No findings reported for this file.