Files
wehub-resource-sync fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

1127 lines
45 KiB
Python

import base64
import json
import logging
from openai import OpenAI
from application.core.settings import settings
from application.llm.base import BaseLLM
from application.storage.storage_creator import StorageCreator
def _truncate_base64_for_logging(messages):
"""
Create a copy of messages with base64 data truncated for readable logging.
Args:
messages: List of message dicts
Returns:
Copy of messages with truncated base64 content
"""
import copy
def truncate_content(content):
if isinstance(content, str):
# Check if it looks like a data URL with base64
if content.startswith("data:") and ";base64," in content:
prefix_end = content.index(";base64,") + len(";base64,")
prefix = content[:prefix_end]
return f"{prefix}[BASE64_DATA_TRUNCATED, length={len(content) - prefix_end}]"
return content
elif isinstance(content, list):
return [truncate_item(item) for item in content]
elif isinstance(content, dict):
return {k: truncate_content(v) for k, v in content.items()}
return content
def truncate_item(item):
if isinstance(item, dict):
result = {}
for k, v in item.items():
if k == "url" and isinstance(v, str) and ";base64," in v:
prefix_end = v.index(";base64,") + len(";base64,")
prefix = v[:prefix_end]
result[k] = f"{prefix}[BASE64_DATA_TRUNCATED, length={len(v) - prefix_end}]"
elif k == "data" and isinstance(v, str) and len(v) > 100:
result[k] = f"[BASE64_DATA_TRUNCATED, length={len(v)}]"
else:
result[k] = truncate_content(v)
return result
return truncate_content(item)
truncated = []
for msg in messages:
msg_copy = copy.copy(msg)
if "content" in msg_copy:
msg_copy["content"] = truncate_content(msg_copy["content"])
truncated.append(msg_copy)
return truncated
class _RespFunction:
"""Minimal stand-in for an OpenAI tool-call ``function`` object."""
def __init__(self, name, arguments):
self.name = name
self.arguments = arguments
class _RespToolCall:
"""Chat-Completions-shaped tool call synthesized from a Responses
``function_call`` item, so the existing OpenAI handler and the streaming
tool-call accumulator consume it unchanged."""
def __init__(self, id, index, name, arguments):
self.id = id
self.index = index
self.type = "function"
self.function = _RespFunction(name, arguments)
class _RespDelta:
"""Stand-in for a streaming chat ``choice.delta``."""
def __init__(self, content=None, tool_calls=None):
self.content = content
self.tool_calls = tool_calls
class _RespMessage:
"""Stand-in for a non-streaming chat ``choice.message``."""
def __init__(self, content=None, tool_calls=None):
self.content = content
self.tool_calls = tool_calls
class _RespChoice:
"""Stand-in for ``response.choices[0]`` (non-streaming) or a streaming
chunk's choice. ``parse_response`` reads ``.message`` or ``.delta`` plus
``.finish_reason``."""
def __init__(self, finish_reason, delta=None, message=None):
self.delta = delta
self.message = message
self.finish_reason = finish_reason
class OpenAILLM(BaseLLM):
provider_name = "openai"
def __init__(
self,
api_key=None,
user_api_key=None,
base_url=None,
http_client=None,
*args,
**kwargs,
):
super().__init__(*args, **kwargs)
self.api_key = api_key or settings.OPENAI_API_KEY or settings.API_KEY
self.user_api_key = user_api_key
# Priority: 1) Parameter base_url, 2) Settings OPENAI_BASE_URL, 3) Default
effective_base_url = None
if base_url and isinstance(base_url, str) and base_url.strip():
effective_base_url = base_url
elif (
isinstance(settings.OPENAI_BASE_URL, str)
and settings.OPENAI_BASE_URL.strip()
):
effective_base_url = settings.OPENAI_BASE_URL
else:
effective_base_url = "https://api.openai.com/v1"
# http_client (set by LLMCreator for BYOM) is a DNS-rebinding-safe
# httpx.Client; without it the SDK re-resolves DNS per request.
if http_client is not None:
self.client = OpenAI(
api_key=self.api_key,
base_url=effective_base_url,
http_client=http_client,
)
else:
self.client = OpenAI(
api_key=self.api_key, base_url=effective_base_url
)
self.storage = StorageCreator.get_storage()
# Per-instance state for the Responses API path. ``_reasoning_for_calls``
# maps a function-call id to the reasoning items that preceded it, so
# the model's chain-of-thought survives the in-turn tool round-trip.
# ``_last_response_id`` is the most recent /v1/responses id, used to
# chain turns when OPENAI_RESPONSES_STORE is enabled.
self._reasoning_for_calls = {}
self._last_response_id = None
def _clean_messages_openai(self, messages):
cleaned_messages = []
for message in messages:
role = message.get("role")
content = message.get("content")
# Reasoning round-trips for providers that demand it
# (DeepSeek thinking mode). Other OpenAI-compatible APIs
# ignore the extra field.
reasoning_content = message.get("reasoning_content")
if role == "model":
role = "assistant"
# Standard format: assistant message with tool_calls (passthrough)
tool_calls = message.get("tool_calls")
if tool_calls and role == "assistant":
cleaned_tcs = []
for tc in tool_calls:
func = tc.get("function", {})
args = func.get("arguments", "{}")
if isinstance(args, dict):
args = json.dumps(self._remove_null_values(args))
elif isinstance(args, str):
try:
parsed = json.loads(args)
args = json.dumps(self._remove_null_values(parsed))
except (json.JSONDecodeError, TypeError):
pass
cleaned_tcs.append({
"id": tc.get("id", ""),
"type": "function",
"function": {"name": func.get("name", ""), "arguments": args},
})
cleaned_assistant: dict = {
"role": "assistant",
"content": None,
"tool_calls": cleaned_tcs,
}
if reasoning_content:
cleaned_assistant["reasoning_content"] = reasoning_content
cleaned_messages.append(cleaned_assistant)
continue
# Standard format: tool message with tool_call_id (passthrough)
tool_call_id = message.get("tool_call_id")
if role == "tool" and tool_call_id is not None:
cleaned_messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"content": content if isinstance(content, str) else json.dumps(content),
})
continue
if role and content is not None:
if isinstance(content, str):
msg_obj: dict = {"role": role, "content": content}
if reasoning_content and role == "assistant":
msg_obj["reasoning_content"] = reasoning_content
cleaned_messages.append(msg_obj)
elif isinstance(content, list):
content_parts = []
for item in content:
# Legacy format support: function_call / function_response
if "function_call" in item:
args = item["function_call"]["args"]
if isinstance(args, str):
try:
args = json.loads(args)
except (json.JSONDecodeError, TypeError):
pass
cleaned_args = self._remove_null_values(args)
tool_call = {
"id": item["function_call"]["call_id"],
"type": "function",
"function": {
"name": item["function_call"]["name"],
"arguments": json.dumps(cleaned_args),
},
}
cleaned_messages.append({
"role": "assistant",
"content": None,
"tool_calls": [tool_call],
})
elif "function_response" in item:
cleaned_messages.append({
"role": "tool",
"tool_call_id": item["function_response"]["call_id"],
"content": json.dumps(
item["function_response"]["response"]["result"]
),
})
elif isinstance(item, dict):
if "type" in item and item["type"] == "text" and "text" in item:
content_parts.append(item)
elif "type" in item and item["type"] == "file" and "file" in item:
content_parts.append(item)
elif "type" in item and item["type"] == "image_url" and "image_url" in item:
content_parts.append(item)
elif "text" in item and "type" not in item:
content_parts.append({"type": "text", "text": item["text"]})
if content_parts:
list_msg: dict = {"role": role, "content": content_parts}
if reasoning_content and role == "assistant":
list_msg["reasoning_content"] = reasoning_content
cleaned_messages.append(list_msg)
else:
raise ValueError(f"Unexpected content type: {type(content)}")
return cleaned_messages
@staticmethod
def _normalize_reasoning_value(value):
"""Normalize reasoning payloads from OpenAI-compatible stream chunks."""
if value is None:
return ""
if isinstance(value, str):
return value
if isinstance(value, list):
return "".join(
OpenAILLM._normalize_reasoning_value(item) for item in value
)
if isinstance(value, dict):
for key in ("text", "content", "value", "reasoning_content", "reasoning"):
normalized = OpenAILLM._normalize_reasoning_value(value.get(key))
if normalized:
return normalized
return ""
for attr in ("text", "content", "value"):
if hasattr(value, attr):
normalized = OpenAILLM._normalize_reasoning_value(getattr(value, attr))
if normalized:
return normalized
return ""
@classmethod
def _extract_reasoning_text(cls, delta):
"""Extract reasoning/thinking tokens from OpenAI-compatible delta chunks."""
if delta is None:
return ""
for key in (
"reasoning_content",
"reasoning",
"thinking",
"thinking_content",
):
value = getattr(delta, key, None)
if value is None and isinstance(delta, dict):
value = delta.get(key)
normalized = cls._normalize_reasoning_value(value)
if normalized:
return normalized
return ""
def _raw_gen(
self,
baseself,
model,
messages,
stream=False,
tools=None,
engine=settings.AZURE_DEPLOYMENT_NAME,
response_format=None,
**kwargs,
):
messages = self._clean_messages_openai(messages)
logging.info(f"Cleaned messages: {_truncate_base64_for_logging(messages)}")
# Convert max_tokens to max_completion_tokens for newer models
if "max_tokens" in kwargs:
kwargs["max_completion_tokens"] = kwargs.pop("max_tokens")
# Defense-in-depth: drop tools / response_format if the
# registry's capability flags deny them.
if tools and not self._supports_tools():
tools = None
if response_format and not self._supports_structured_output():
response_format = None
previous_response_id = kwargs.pop("previous_response_id", None)
if self._uses_responses_api():
return self._responses_gen(
model,
messages,
tools=tools,
response_format=response_format,
previous_response_id=previous_response_id,
**kwargs,
)
self._apply_reasoning_effort(kwargs)
request_params = {
"model": model,
"messages": messages,
"stream": stream,
**kwargs,
}
if tools:
request_params["tools"] = tools
if response_format:
request_params["response_format"] = response_format
response = self.client.chat.completions.create(**request_params)
logging.info(f"OpenAI response: {response}")
if tools:
return response.choices[0]
else:
return response.choices[0].message.content
def _raw_gen_stream(
self,
baseself,
model,
messages,
stream=True,
tools=None,
engine=settings.AZURE_DEPLOYMENT_NAME,
response_format=None,
**kwargs,
):
messages = self._clean_messages_openai(messages)
logging.info(f"Cleaned messages: {_truncate_base64_for_logging(messages)}")
# Convert max_tokens to max_completion_tokens for newer models
if "max_tokens" in kwargs:
kwargs["max_completion_tokens"] = kwargs.pop("max_tokens")
# See _raw_gen for rationale — drop tools/response_format when the
# registry-provided capabilities say the model doesn't support them.
if tools and not self._supports_tools():
tools = None
if response_format and not self._supports_structured_output():
response_format = None
previous_response_id = kwargs.pop("previous_response_id", None)
if self._uses_responses_api():
yield from self._responses_gen_stream(
model,
messages,
tools=tools,
response_format=response_format,
previous_response_id=previous_response_id,
**kwargs,
)
return
self._apply_reasoning_effort(kwargs)
request_params = {
"model": model,
"messages": messages,
"stream": stream,
**kwargs,
}
if tools:
request_params["tools"] = tools
if response_format:
request_params["response_format"] = response_format
response = self.client.chat.completions.create(**request_params)
try:
for line in response:
logging.debug(f"OpenAI stream line: {line}")
if not getattr(line, "choices", None):
continue
choice = line.choices[0]
delta = getattr(choice, "delta", None)
reasoning_text = self._extract_reasoning_text(delta)
if reasoning_text:
yield {"type": "thought", "thought": reasoning_text}
content = getattr(delta, "content", None)
if isinstance(content, str) and content:
yield content
continue
has_tool_calls = bool(getattr(delta, "tool_calls", None))
finish_reason = getattr(choice, "finish_reason", None)
# Yield non-content chunks only when needed for tool-call handling.
if has_tool_calls or finish_reason == "tool_calls":
yield choice
finally:
if hasattr(response, "close"):
response.close()
# ---- Responses API (/v1/responses) ----
def _uses_responses_api(self):
"""True when the model's registry capability opts it into the
``/v1/responses`` endpoint."""
return (
self.capabilities is not None
and getattr(self.capabilities, "api_flavor", "chat_completions")
== "responses"
)
@staticmethod
def _responses_content_parts(role, content):
"""Translate a cleaned chat ``content`` value into Responses content
parts. The Responses API enforces the content-part type by message
role: assistant turns require ``output_text`` (``input_text`` is
rejected with a 400), while user/system turns require ``input_text``.
Images/files use ``input_image``/``input_file``.
"""
text_type = "output_text" if role == "assistant" else "input_text"
parts = []
if content is None:
return parts
if isinstance(content, str):
if content:
parts.append({"type": text_type, "text": content})
return parts
if isinstance(content, list):
for item in content:
if not isinstance(item, dict):
continue
itype = item.get("type")
if itype == "text":
parts.append({"type": text_type, "text": item.get("text", "")})
elif itype == "image_url":
url = (item.get("image_url") or {}).get("url")
if url:
parts.append({
"type": "input_image",
"image_url": url,
"detail": "auto",
})
elif itype == "file":
file_obj = item.get("file") or {}
file_part = {"type": "input_file"}
for key in ("file_id", "filename", "file_data"):
if file_obj.get(key):
file_part[key] = file_obj[key]
parts.append(file_part)
return parts
def _to_responses_input(self, messages):
"""Translate cleaned Chat-Completions messages into a Responses
``input`` item list.
Reasoning items captured during the in-turn tool loop are re-injected
ahead of the function calls they belong to (deduped by id) so the
model keeps its chain-of-thought across the round-trip.
"""
input_items = []
emitted_reasoning = set()
for message in messages:
role = message.get("role")
tool_calls = message.get("tool_calls")
if tool_calls and role == "assistant":
for tc in tool_calls:
call_id = tc.get("id", "")
for item in self._reasoning_for_calls.get(call_id, []):
item_id = item.get("id")
if item_id and item_id in emitted_reasoning:
continue
if item_id:
emitted_reasoning.add(item_id)
input_items.append(item)
func = tc.get("function", {})
input_items.append({
"type": "function_call",
"call_id": call_id,
"name": func.get("name", ""),
"arguments": func.get("arguments", "") or "{}",
})
continue
tool_call_id = message.get("tool_call_id")
if role == "tool" and tool_call_id is not None:
tool_content = message.get("content")
input_items.append({
"type": "function_call_output",
"call_id": tool_call_id,
"output": (
tool_content
if isinstance(tool_content, str)
else json.dumps(tool_content)
),
})
continue
parts = self._responses_content_parts(role, message.get("content"))
if parts:
input_items.append({"role": role, "content": parts})
return input_items
@staticmethod
def _trim_for_previous_response(messages):
"""When chaining via ``previous_response_id`` the server already holds
the earlier turns, so only system context plus everything after the
last completed assistant response needs to be sent again."""
last_assistant = -1
for i, message in enumerate(messages):
if message.get("role") == "assistant" and not message.get(
"tool_calls"
):
last_assistant = i
if last_assistant < 0:
return messages
head = [
m
for m in messages[: last_assistant + 1]
if m.get("role") == "system"
]
return head + messages[last_assistant + 1:]
@staticmethod
def _to_responses_tools(tools):
"""Flatten Chat-Completions tool defs into Responses tool defs.
``strict`` is left False so schemas that were valid on Chat
Completions are not newly rejected by the stricter Responses default.
"""
converted = []
for tool in tools or []:
if tool.get("type") == "function" and isinstance(
tool.get("function"), dict
):
fn = tool["function"]
converted.append({
"type": "function",
"name": fn.get("name", ""),
"description": fn.get("description", ""),
"parameters": fn.get("parameters", {}),
"strict": False,
})
else:
converted.append(tool)
return converted
@staticmethod
def _responses_text_format(response_format):
"""Map a Chat-Completions ``response_format`` to a Responses
``text.format`` object."""
if not isinstance(response_format, dict):
return None
if response_format.get("type") == "json_schema":
js = response_format.get("json_schema", {})
fmt = {"type": "json_schema", "name": js.get("name", "response")}
if "schema" in js:
fmt["schema"] = js["schema"]
if "strict" in js:
fmt["strict"] = js["strict"]
return fmt
if response_format.get("type") == "json_object":
return {"type": "json_object"}
return None
def _build_responses_params(
self,
model,
input_items,
tools,
response_format,
previous_response_id,
stream,
kwargs,
):
"""Assemble the kwargs for ``client.responses.create``. Only known,
Responses-compatible keys are forwarded — unknown chat-only kwargs
are dropped so the API does not reject the request."""
params = {"model": model, "input": input_items, "stream": stream}
max_out = kwargs.pop("max_completion_tokens", None)
if max_out is None:
max_out = kwargs.pop("max_tokens", None)
if max_out is not None:
params["max_output_tokens"] = max_out
effort = (
getattr(self.capabilities, "reasoning_effort", None)
if self.capabilities is not None
else None
)
if effort:
params["reasoning"] = {"effort": effort, "summary": "auto"}
if response_format:
fmt = self._responses_text_format(response_format)
if fmt:
params["text"] = {"format": fmt}
if tools:
params["tools"] = self._to_responses_tools(tools)
store = bool(settings.OPENAI_RESPONSES_STORE)
params["store"] = store
if store and previous_response_id:
params["previous_response_id"] = previous_response_id
# Always request encrypted reasoning content so reasoning items can be
# replayed by value across the in-turn tool loop — this keeps
# carryover working whether or not the response is also retained
# server-side (store=true).
params["include"] = ["reasoning.encrypted_content"]
return params
@staticmethod
def _reasoning_item_to_dict(item):
"""Serialize a Responses ``reasoning`` output item into the input
shape needed to feed it back on the next call."""
result = {"type": "reasoning", "id": getattr(item, "id", None)}
encrypted = getattr(item, "encrypted_content", None)
if encrypted is not None:
result["encrypted_content"] = encrypted
summary = getattr(item, "summary", None) or []
serialized = []
for part in summary:
if isinstance(part, dict):
serialized.append(part)
else:
serialized.append({
"type": getattr(part, "type", "summary_text"),
"text": getattr(part, "text", ""),
})
result["summary"] = serialized
return result
def _record_responses_metadata(self, response):
rid = getattr(response, "id", None)
if rid:
self._last_response_id = rid
def _remember_reasoning(self, tool_calls, reasoning_items):
"""Key captured reasoning items by each function-call id for replay
on the next in-turn request."""
if not reasoning_items:
return
for tc in tool_calls:
self._reasoning_for_calls[tc.id] = reasoning_items
def _parse_responses_output(self, response):
"""Walk a non-streaming Responses ``output`` array into
``(content, tool_calls, reasoning_items)``."""
content_parts = []
tool_calls = []
reasoning_items = []
for item in getattr(response, "output", None) or []:
itype = getattr(item, "type", None)
if itype == "reasoning":
reasoning_items.append(self._reasoning_item_to_dict(item))
elif itype == "message":
for part in getattr(item, "content", None) or []:
if getattr(part, "type", None) == "output_text":
content_parts.append(getattr(part, "text", "") or "")
elif itype == "function_call":
tool_calls.append(_RespToolCall(
id=getattr(item, "call_id", "") or getattr(item, "id", ""),
index=len(tool_calls),
name=getattr(item, "name", "") or "",
arguments=getattr(item, "arguments", "") or "",
))
return "".join(content_parts), tool_calls, reasoning_items
def _responses_gen(
self,
model,
messages,
tools=None,
response_format=None,
previous_response_id=None,
**kwargs,
):
if previous_response_id and settings.OPENAI_RESPONSES_STORE:
messages = self._trim_for_previous_response(messages)
input_items = self._to_responses_input(messages)
params = self._build_responses_params(
model,
input_items,
tools,
response_format,
previous_response_id,
stream=False,
kwargs=kwargs,
)
response = self.client.responses.create(**params)
logging.info(f"OpenAI responses output: {getattr(response, 'output', None)}")
self._record_responses_metadata(response)
content, tool_calls, reasoning_items = self._parse_responses_output(
response
)
if tools:
self._remember_reasoning(tool_calls, reasoning_items)
message = _RespMessage(
content=content or None, tool_calls=tool_calls or None
)
return _RespChoice(
finish_reason="tool_calls" if tool_calls else "stop",
message=message,
)
return content or ""
def _responses_gen_stream(
self,
model,
messages,
tools=None,
response_format=None,
previous_response_id=None,
**kwargs,
):
if previous_response_id and settings.OPENAI_RESPONSES_STORE:
messages = self._trim_for_previous_response(messages)
input_items = self._to_responses_input(messages)
params = self._build_responses_params(
model,
input_items,
tools,
response_format,
previous_response_id,
stream=True,
kwargs=kwargs,
)
response = self.client.responses.create(**params)
func_calls = {}
reasoning_items = []
try:
for event in response:
etype = getattr(event, "type", "")
if etype == "response.output_text.delta":
delta = getattr(event, "delta", "")
if delta:
yield delta
elif etype == "response.reasoning_summary_text.delta":
delta = getattr(event, "delta", "")
if delta:
yield {"type": "thought", "thought": delta}
elif etype == "response.output_item.added":
item = getattr(event, "item", None)
if getattr(item, "type", None) == "function_call":
index = getattr(event, "output_index", len(func_calls))
func_calls[index] = {
"call_id": (
getattr(item, "call_id", "")
or getattr(item, "id", "")
),
"name": getattr(item, "name", "") or "",
"arguments": "",
}
elif etype == "response.function_call_arguments.delta":
index = getattr(event, "output_index", None)
if index in func_calls:
func_calls[index]["arguments"] += (
getattr(event, "delta", "") or ""
)
elif etype == "response.function_call_arguments.done":
index = getattr(event, "output_index", None)
if index in func_calls:
done_args = getattr(event, "arguments", None)
if done_args is not None:
func_calls[index]["arguments"] = done_args
elif etype == "response.output_item.done":
item = getattr(event, "item", None)
if getattr(item, "type", None) == "reasoning":
reasoning_items.append(
self._reasoning_item_to_dict(item)
)
elif etype == "response.completed":
self._record_responses_metadata(
getattr(event, "response", None)
)
if func_calls:
tool_calls = []
for position, index in enumerate(sorted(func_calls)):
entry = func_calls[index]
tool_calls.append(_RespToolCall(
id=entry["call_id"],
index=position,
name=entry["name"],
arguments=entry["arguments"],
))
self._remember_reasoning(tool_calls, reasoning_items)
yield _RespChoice(
finish_reason="tool_calls",
delta=_RespDelta(tool_calls=tool_calls),
)
elif etype in ("response.failed", "error"):
resp = getattr(event, "response", None)
err = (
getattr(resp, "error", None)
or getattr(event, "message", None)
or "Responses stream error"
)
raise RuntimeError(f"Responses API stream error: {err}")
finally:
if hasattr(response, "close"):
response.close()
def _supports_tools(self):
# When the LLM was constructed via LLMCreator with a registered
# AvailableModel, ``self.capabilities`` is the per-model record.
# BYOM users can disable tool support; respect that. Otherwise
# OpenAI's API supports tools by default.
if self.capabilities is not None:
return bool(self.capabilities.supports_tools)
return True
def _supports_structured_output(self):
if self.capabilities is not None:
return bool(self.capabilities.supports_structured_output)
return True
def _apply_reasoning_effort(self, kwargs):
"""Inject the model's configured reasoning_effort into ``kwargs``.
No-op when the caller already set one, when no registry capabilities
are attached, or when the model has no configured effort. Read from
per-model capabilities (not the caller) so a cross-provider fallback
applies its own model's effort rather than inheriting the primary's.
"""
if "reasoning_effort" in kwargs:
return
if self.capabilities is None:
return
effort = getattr(self.capabilities, "reasoning_effort", None)
if effort:
kwargs["reasoning_effort"] = effort
def prepare_structured_output_format(self, json_schema, strict=True):
if not json_schema:
return None
try:
def add_additional_properties_false(schema_obj):
if isinstance(schema_obj, dict):
schema_copy = schema_obj.copy()
if schema_copy.get("type") == "object":
schema_copy["additionalProperties"] = False
# Ensure 'required' includes all properties for OpenAI strict mode
if "properties" in schema_copy:
schema_copy["required"] = list(
schema_copy["properties"].keys()
)
for key, value in schema_copy.items():
if key == "properties" and isinstance(value, dict):
schema_copy[key] = {
prop_name: add_additional_properties_false(prop_schema)
for prop_name, prop_schema in value.items()
}
elif key == "items" and isinstance(value, dict):
schema_copy[key] = add_additional_properties_false(value)
elif key in ["anyOf", "oneOf", "allOf"] and isinstance(
value, list
):
schema_copy[key] = [
add_additional_properties_false(sub_schema)
for sub_schema in value
]
return schema_copy
return schema_obj
# Strict mode requires additionalProperties:false + all-required on every
# object (OpenAI Structured Outputs). When strict is false (OpenAI's
# lenient json_schema), pass the schema through unchanged.
processed_schema = (
add_additional_properties_false(json_schema) if strict else json_schema
)
result = {
"type": "json_schema",
"json_schema": {
"name": processed_schema.get("name", "response"),
"description": processed_schema.get(
"description", "Structured response"
),
"schema": processed_schema,
"strict": strict,
},
}
return result
except Exception as e:
logging.error(f"Error preparing structured output format: {e}")
return None
def get_supported_attachment_types(self):
"""
Return a list of MIME types supported by OpenAI for file uploads.
This reads from the model config to ensure consistency.
If no model config found, falls back to images only (safest default).
Returns:
list: List of supported MIME types
"""
# Per-model caps from the registry win when present — a BYOM
# endpoint that doesn't accept images would otherwise still be
# sent base64 image parts because the OpenAI default below
# advertises the image alias unconditionally.
if self.capabilities is not None:
return list(self.capabilities.supported_attachment_types or [])
from application.core.model_yaml import resolve_attachment_alias
return resolve_attachment_alias("image")
def prepare_messages_with_attachments(self, messages, attachments=None):
"""
Process attachments using OpenAI's file API for more efficient handling.
Args:
messages (list): List of message dictionaries.
attachments (list): List of attachment dictionaries with content and metadata.
Returns:
list: Messages formatted with file references for OpenAI API.
"""
if not attachments:
return messages
prepared_messages = messages.copy()
# Find the user message to attach file_id to the last one
user_message_index = None
for i in range(len(prepared_messages) - 1, -1, -1):
if prepared_messages[i].get("role") == "user":
user_message_index = i
break
if user_message_index is None:
user_message = {"role": "user", "content": []}
prepared_messages.append(user_message)
user_message_index = len(prepared_messages) - 1
if isinstance(prepared_messages[user_message_index].get("content"), str):
text_content = prepared_messages[user_message_index]["content"]
prepared_messages[user_message_index]["content"] = [
{"type": "text", "text": text_content}
]
elif not isinstance(prepared_messages[user_message_index].get("content"), list):
prepared_messages[user_message_index]["content"] = []
for attachment in attachments:
mime_type = attachment.get("mime_type")
logging.info(f"Processing attachment with mime_type: {mime_type}, has_data: {'data' in attachment}, has_path: {'path' in attachment}")
if mime_type and mime_type.startswith("image/"):
try:
# Check if this is a pre-converted image (from PDF-to-image conversion)
if "data" in attachment:
base64_image = attachment["data"]
else:
base64_image = self._get_base64_image(attachment)
prepared_messages[user_message_index]["content"].append(
{
"type": "image_url",
"image_url": {
"url": f"data:{mime_type};base64,{base64_image}"
},
}
)
except Exception as e:
logging.error(
f"Error processing image attachment: {e}", exc_info=True
)
if "content" in attachment:
prepared_messages[user_message_index]["content"].append(
{
"type": "text",
"text": f"[Image could not be processed: {attachment.get('path', 'unknown')}]",
}
)
# Handle PDFs using the file API
elif mime_type == "application/pdf":
logging.info(f"Attempting to upload PDF to OpenAI: {attachment.get('path', 'unknown')}")
try:
file_id = self._upload_file_to_openai(attachment)
prepared_messages[user_message_index]["content"].append(
{"type": "file", "file": {"file_id": file_id}}
)
except Exception as e:
logging.error(f"Error uploading PDF to OpenAI: {e}", exc_info=True)
if "content" in attachment:
prepared_messages[user_message_index]["content"].append(
{
"type": "text",
"text": f"File content:\n\n{attachment['content']}",
}
)
else:
logging.warning(f"Unsupported attachment type in OpenAI provider: {mime_type}")
return prepared_messages
def _get_base64_image(self, attachment):
"""
Convert an image file to base64 encoding.
Args:
attachment (dict): Attachment dictionary with path and metadata.
Returns:
str: Base64-encoded image data.
"""
file_path = attachment.get("path")
if not file_path:
raise ValueError("No file path provided in attachment")
try:
with self.storage.get_file(file_path) as image_file:
return base64.b64encode(image_file.read()).decode("utf-8")
except FileNotFoundError:
raise FileNotFoundError(f"File not found: {file_path}")
def _upload_file_to_openai(self, attachment):
"""
Upload a file to OpenAI and return the file_id.
Args:
attachment (dict): Attachment dictionary with path and metadata.
Expected keys:
- path: Path to the file
- id: Optional MongoDB ID for caching
Returns:
str: OpenAI file_id for the uploaded file.
"""
if "openai_file_id" in attachment:
return attachment["openai_file_id"]
file_path = attachment.get("path")
if not self.storage.file_exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
try:
def _upload(local_path, **_kwargs):
with open(local_path, "rb") as uploaded_file:
return self.client.files.create(
file=uploaded_file,
purpose="assistants",
).id
file_id = self.storage.process_file(file_path, _upload)
# Cache the OpenAI file id on the attachment row so we don't
# re-upload the same blob on the next LLM call. Prefer the PG
# UUID (``id``) when present; fall back to the legacy Mongo
# ObjectId string (``_id``). Opened per-write — this runs
# inside the hot LLM path, so we don't want a long-lived
# session wrapping the generator.
attachment_id = attachment.get("id") or attachment.get("_id")
if attachment_id:
user_id = None
decoded = getattr(self, "decoded_token", None)
if isinstance(decoded, dict):
user_id = decoded.get("sub")
from application.storage.db.repositories.attachments import (
AttachmentsRepository,
)
from application.storage.db.session import db_session
try:
with db_session() as conn:
AttachmentsRepository(conn).update_any(
str(attachment_id),
user_id,
{"openai_file_id": file_id},
)
except Exception as cache_err:
logging.warning(
f"Failed to cache openai_file_id on attachment {attachment_id}: {cache_err}"
)
return file_id
except Exception as e:
logging.error(f"Error uploading file to OpenAI: {e}", exc_info=True)
raise