mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-10-25 17:36:29 +08:00
feat: add open_ai_api_type to route OpenAI requests via Responses API
Some endpoints no longer serve /chat/completions for gpt-5.x models. Setting open_ai_api_type to "responses" (or OPEN_AI_API_TYPE env) sends agent, title generation and vision requests to /responses. Default "auto" keeps the existing behavior. Reasoning effort follows the thinking toggle. Co-authored-by: cowagent <cow@cowagent.ai>
This commit is contained in:
1 parent
59c0da12e3
commit
7fe7163593
10 files changed
+363
-34
No files matched your search
@@ -113,6 +113,7 @@ class VisionProvider:
|
||||
model_override: Optional[str] = None
|
||||
use_bot: bool = False # When True, call via bot.call_vision instead of raw HTTP
|
||||
fallback_bot: Any = None # Bot instance for non-main-model providers
|
||||
use_responses: bool = False # When True, call /responses instead of /chat/completions
|
||||
|
||||
|
||||
class VisionAPIError(Exception):
|
||||
@@ -602,11 +603,17 @@ class Vision(BaseTool):
|
||||
model_override = preferred_model if (
|
||||
preferred_model and preferred_model.lower().startswith(_OPENAI_MODEL_PREFIXES)
|
||||
) else None
|
||||
from models.openai import responses_adapter
|
||||
use_responses = (
|
||||
responses_adapter.resolve_api_type(conf().get("open_ai_api_type"))
|
||||
== responses_adapter.API_TYPE_RESPONSES
|
||||
)
|
||||
return VisionProvider(
|
||||
name="OpenAI",
|
||||
api_key=api_key,
|
||||
api_base=self._ensure_v1(api_base),
|
||||
model_override=model_override,
|
||||
use_responses=use_responses,
|
||||
)
|
||||
|
||||
def _build_linkai_provider(self, preferred_model: Optional[str] = None) -> Optional[VisionProvider]:
|
||||
@@ -863,8 +870,19 @@ class Vision(BaseTool):
|
||||
**provider.extra_headers,
|
||||
}
|
||||
|
||||
endpoint = "/chat/completions"
|
||||
if provider.use_responses:
|
||||
from models.openai import responses_adapter
|
||||
from models.openai_compatible_bot import OpenAICompatibleBot
|
||||
payload = responses_adapter.build_responses_payload(
|
||||
model=model,
|
||||
messages=payload["messages"],
|
||||
reasoning_effort=OpenAICompatibleBot._responses_reasoning_effort(model, {}),
|
||||
)
|
||||
endpoint = "/responses"
|
||||
|
||||
resp = requests.post(
|
||||
f"{provider.api_base}/chat/completions",
|
||||
f"{provider.api_base}{endpoint}",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
@@ -875,10 +893,13 @@ class Vision(BaseTool):
|
||||
|
||||
data = resp.json()
|
||||
|
||||
if "error" in data:
|
||||
if data.get("error"):
|
||||
msg = data["error"].get("message", "Unknown API error")
|
||||
raise VisionAPIError(f"API error - {msg}")
|
||||
|
||||
if provider.use_responses:
|
||||
data = responses_adapter.responses_to_chat_completion(data)
|
||||
|
||||
content = ""
|
||||
choices = data.get("choices", [])
|
||||
if choices:
|
||||
|
||||
@@ -24,6 +24,8 @@ available_setting = {
|
||||
"open_ai_api_key": "", # openai api key
|
||||
# openai api base; when use_azure_chatgpt is true, set the matching api base
|
||||
"open_ai_api_base": "https://api.openai.com/v1",
|
||||
# openai api protocol: "auto" (Responses only for models that require it), "chat" (/chat/completions) or "responses" (/responses)
|
||||
"open_ai_api_type": "auto",
|
||||
"claude_api_base": "https://api.anthropic.com/v1", # claude api base
|
||||
"gemini_api_base": "https://generativelanguage.googleapis.com", # gemini api base
|
||||
"custom_api_key": "", # custom OpenAI-compatible provider api key (used when bot_type is "custom"); legacy single-provider field
|
||||
|
||||
@@ -25,6 +25,7 @@ OpenAI は最も広範な機能をカバーするベンダーで、テキスト
|
||||
| `model` | OpenAI API の [model パラメータ](https://platform.openai.com/docs/models) と同じです。`gpt-5.6-luna`、`gpt-6-astra`、`gpt-5.6-terra`、`gpt-5.6-sol`、`gpt-5.5`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano`、`gpt-5` シリーズ、`gpt-4.1` などをサポート。Agent モードのデフォルトは `gpt-5.6-luna`、`gpt-6-astra` は最強のフラッグシップ(最高の知能・高コスト、ツール呼び出しは Responses API 経由)、コストパフォーマンスを重視する場合は `gpt-5.4` に変更可能 |
|
||||
| `open_ai_api_key` | [OpenAI プラットフォーム](https://platform.openai.com/api-keys) で作成 |
|
||||
| `open_ai_api_base` | 任意。サードパーティのプロキシに接続するために変更可能 |
|
||||
| `open_ai_api_type` | 任意。API プロトコル、デフォルトは `auto`(`gpt-6` シリーズのみ Responses API、それ以外は Chat Completions)。接続先が `/chat/completions` を提供しなくなった場合は `responses` に設定すると、すべてのリクエストが `/responses` 経由になります。`chat` は常に `/chat/completions` を使用します。Docker デプロイでは環境変数 `OPEN_AI_API_TYPE` で設定できます |
|
||||
| `bot_type` | OpenAI 公式モデルを使用する場合は不要。互換プロトコルでベンダーモデルに接続する場合は `openai` に設定 |
|
||||
|
||||
## 画像理解
|
||||
|
||||
@@ -25,6 +25,7 @@ OpenAI offers the most complete coverage and can simultaneously serve text chat,
|
||||
| `model` | Same as OpenAI's [model parameter](https://platform.openai.com/docs/models); supports `gpt-5.6-luna`, `gpt-6-astra`, `gpt-5.6-terra`, `gpt-5.6-sol`, `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, the `gpt-5` series, `gpt-4.1`, etc. Agent mode defaults to `gpt-5.6-luna`; `gpt-6-astra` is the top flagship (most intelligent, higher cost, tool calling goes through the Responses API); use `gpt-5.4` for better cost-efficiency |
|
||||
| `open_ai_api_key` | Create one on the [OpenAI Platform](https://platform.openai.com/api-keys) |
|
||||
| `open_ai_api_base` | Optional; change it to access a third-party proxy |
|
||||
| `open_ai_api_type` | Optional API protocol, defaults to `auto` (only the `gpt-6` series uses the Responses API, others use Chat Completions). If your endpoint no longer serves `/chat/completions`, set it to `responses` to send all requests to `/responses`; `chat` always uses `/chat/completions`. For Docker deployments, set it via the `OPEN_AI_API_TYPE` environment variable |
|
||||
| `bot_type` | Not required when using OpenAI's official models; set to `openai` when accessing other providers via the compatible protocol |
|
||||
|
||||
## Image Understanding
|
||||
|
||||
@@ -25,6 +25,7 @@ OpenAI 是覆盖最完整的厂商,可同时承担文本对话、视觉理解
|
||||
| `model` | 与 OpenAI 接口的 [model 参数](https://platform.openai.com/docs/models) 一致,支持 `gpt-5.6-luna`、`gpt-6-astra`、`gpt-5.6-terra`、`gpt-5.6-sol`、`gpt-5.5`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano`、`gpt-5` 系列、`gpt-4.1` 等;Agent 模式默认 `gpt-5.6-luna`,`gpt-6-astra` 为最强旗舰(智能最高、成本较高,工具调用走 Responses 接口),追求性价比可改为 `gpt-5.4` |
|
||||
| `open_ai_api_key` | 在 [OpenAI 平台](https://platform.openai.com/api-keys) 创建 |
|
||||
| `open_ai_api_base` | 可选,修改可接入第三方代理 |
|
||||
| `open_ai_api_type` | 可选,接口协议,默认 `auto`(仅 `gpt-6` 系列走 Responses 接口,其余走 Chat Completions);若接入的服务不再提供 `/chat/completions`,设为 `responses` 让所有请求改走 `/responses`;设为 `chat` 则始终走 `/chat/completions`。Docker 部署可通过环境变量 `OPEN_AI_API_TYPE` 设置 |
|
||||
| `bot_type` | 使用 OpenAI 官方模型时无需填写;通过兼容协议接入厂商模型时需设为 `openai` |
|
||||
|
||||
## 图像理解
|
||||
|
||||
@@ -10,6 +10,7 @@ from models.openai.openai_compat import (
|
||||
wrap_http_error,
|
||||
)
|
||||
from models.openai.openai_http_client import OpenAIHTTPClient, OpenAIHTTPError
|
||||
from models.openai import responses_adapter
|
||||
import requests
|
||||
from common import const
|
||||
from common.i18n import t as _t
|
||||
@@ -99,8 +100,20 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
|
||||
'default_top_p': conf().get("top_p", 1.0),
|
||||
'default_frequency_penalty': conf().get("frequency_penalty", 0.0),
|
||||
'default_presence_penalty': conf().get("presence_penalty", 0.0),
|
||||
'api_type': self._openai_api_type(),
|
||||
}
|
||||
|
||||
def _openai_api_type(self) -> str:
|
||||
"""``open_ai_api_type`` applies to the OpenAI endpoint only; custom
|
||||
providers keep the default routing."""
|
||||
is_custom, _ = parse_custom_bot_type(self._bot_type)
|
||||
if is_custom:
|
||||
return responses_adapter.API_TYPE_AUTO
|
||||
return responses_adapter.resolve_api_type(conf().get("open_ai_api_type"))
|
||||
|
||||
def _use_responses_for_plain_calls(self) -> bool:
|
||||
return self._openai_api_type() == responses_adapter.API_TYPE_RESPONSES
|
||||
|
||||
def _get_http_client(self) -> OpenAIHTTPClient:
|
||||
"""Override the default HTTP client to reuse our pre-configured one."""
|
||||
return self._http_client
|
||||
@@ -235,13 +248,22 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
|
||||
logger.info(f"[CHATGPT] Calling vision API with model: {model}")
|
||||
|
||||
# Call OpenAI-compatible API via HTTP
|
||||
response = self._http_client.chat_completions(
|
||||
api_key=api_key or None,
|
||||
api_base=api_base or None,
|
||||
model=model,
|
||||
messages=messages,
|
||||
max_tokens=1000,
|
||||
)
|
||||
if self._use_responses_for_plain_calls():
|
||||
response = self._responses_as_chat_completion(
|
||||
model=model,
|
||||
messages=messages,
|
||||
api_key=api_key or None,
|
||||
api_base=api_base or None,
|
||||
max_tokens=1000,
|
||||
)
|
||||
else:
|
||||
response = self._http_client.chat_completions(
|
||||
api_key=api_key or None,
|
||||
api_base=api_base or None,
|
||||
model=model,
|
||||
messages=messages,
|
||||
max_tokens=1000,
|
||||
)
|
||||
|
||||
content = response["choices"][0]["message"]["content"]
|
||||
logger.info(f"[CHATGPT] Vision API response: {content[:100]}...")
|
||||
@@ -279,12 +301,22 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
|
||||
# - request_timeout / timeout -> per-call timeout
|
||||
call_args = dict(args)
|
||||
timeout = call_args.pop("request_timeout", None) or call_args.pop("timeout", None)
|
||||
response = self._http_client.chat_completions(
|
||||
api_key=api_key or None,
|
||||
timeout=timeout,
|
||||
messages=session.messages,
|
||||
**call_args,
|
||||
)
|
||||
if self._use_responses_for_plain_calls():
|
||||
# Sampling params are dropped: Responses reasoning models reject them.
|
||||
response = self._responses_as_chat_completion(
|
||||
model=call_args.get("model"),
|
||||
messages=session.messages,
|
||||
api_key=api_key or None,
|
||||
timeout=timeout,
|
||||
max_tokens=call_args.get("max_tokens"),
|
||||
)
|
||||
else:
|
||||
response = self._http_client.chat_completions(
|
||||
api_key=api_key or None,
|
||||
timeout=timeout,
|
||||
messages=session.messages,
|
||||
**call_args,
|
||||
)
|
||||
logger.info("[ChatGPT] reply={}, total_tokens={}".format(
|
||||
response["choices"][0]["message"]["content"],
|
||||
response["usage"]["total_tokens"]
|
||||
@@ -443,6 +475,11 @@ class AzureChatGPTBot(ChatGPTBot):
|
||||
config["api_base"] = None
|
||||
return config
|
||||
|
||||
def _openai_api_type(self) -> str:
|
||||
# Azure's Responses endpoint has a different URL shape than the
|
||||
# deployment-scoped base built above, so keep the default routing.
|
||||
return responses_adapter.API_TYPE_AUTO
|
||||
|
||||
|
||||
class _AzureChatHTTPClient(OpenAIHTTPClient):
|
||||
"""Subclass that injects Azure's ``api-version`` query param and ``api-key``
|
||||
|
||||
@@ -60,6 +60,7 @@ class OpenAIBot(Bot, OpenAIImage, OpenAICompatibleBot):
|
||||
'default_top_p': conf().get("top_p", 1.0),
|
||||
'default_frequency_penalty': conf().get("frequency_penalty", 0.0),
|
||||
'default_presence_penalty': conf().get("presence_penalty", 0.0),
|
||||
'api_type': conf().get("open_ai_api_type"),
|
||||
}
|
||||
|
||||
def _get_http_client(self) -> OpenAIHTTPClient:
|
||||
|
||||
@@ -36,6 +36,15 @@ _RESPONSES_ONLY_PREFIXES = ("gpt-6",)
|
||||
_MIN_EFFORT_FALLBACK = "low"
|
||||
_UNSUPPORTED_EFFORTS = {"none", "minimal"}
|
||||
|
||||
# Values of the ``open_ai_api_type`` config key:
|
||||
# auto - Responses only for models that require it (gpt-6*), else Chat Completions
|
||||
# chat - always Chat Completions
|
||||
# responses - always Responses (for endpoints that no longer serve /chat/completions)
|
||||
API_TYPE_AUTO = "auto"
|
||||
API_TYPE_CHAT = "chat"
|
||||
API_TYPE_RESPONSES = "responses"
|
||||
_API_TYPES = {API_TYPE_AUTO, API_TYPE_CHAT, API_TYPE_RESPONSES}
|
||||
|
||||
|
||||
def is_responses_only_model(model_name: str) -> bool:
|
||||
"""Whether the model must use the Responses API for tool calling."""
|
||||
@@ -45,12 +54,32 @@ def is_responses_only_model(model_name: str) -> bool:
|
||||
return name.startswith(_RESPONSES_ONLY_PREFIXES)
|
||||
|
||||
|
||||
def normalize_effort(effort: Optional[str]) -> Optional[str]:
|
||||
"""Map a reasoning effort to one Responses-only models accept."""
|
||||
def resolve_api_type(api_type: Optional[str]) -> str:
|
||||
"""Normalize an ``open_ai_api_type`` value; unknown values fall back to auto."""
|
||||
value = str(api_type or "").strip().lower()
|
||||
return value if value in _API_TYPES else API_TYPE_AUTO
|
||||
|
||||
|
||||
def use_responses_api(model_name: str, api_type: Optional[str] = None) -> bool:
|
||||
"""Whether a tool-calling request for ``model_name`` should use Responses."""
|
||||
mode = resolve_api_type(api_type)
|
||||
if mode == API_TYPE_RESPONSES:
|
||||
return True
|
||||
if mode == API_TYPE_CHAT:
|
||||
return False
|
||||
return is_responses_only_model(model_name)
|
||||
|
||||
|
||||
def normalize_effort(effort: Optional[str], model: Optional[str] = None) -> Optional[str]:
|
||||
"""Map a reasoning effort to one the target model accepts on Responses.
|
||||
|
||||
Only Responses-only models (gpt-6*) lack the "none"/"minimal" tiers; other
|
||||
models (e.g. gpt-5.x) receive the value unchanged.
|
||||
"""
|
||||
if not effort:
|
||||
return None
|
||||
value = str(effort).strip().lower()
|
||||
if value in _UNSUPPORTED_EFFORTS:
|
||||
if value in _UNSUPPORTED_EFFORTS and (model is None or is_responses_only_model(model)):
|
||||
return _MIN_EFFORT_FALLBACK
|
||||
return value
|
||||
|
||||
@@ -207,7 +236,7 @@ def build_responses_payload(
|
||||
payload["tool_choice"] = tool_choice or "auto"
|
||||
if max_output_tokens:
|
||||
payload["max_output_tokens"] = max_output_tokens
|
||||
effort = normalize_effort(reasoning_effort)
|
||||
effort = normalize_effort(reasoning_effort, model)
|
||||
if effort:
|
||||
payload["reasoning"] = {"effort": effort}
|
||||
if response_format and response_format.get("type") == "json_object":
|
||||
|
||||
@@ -62,6 +62,7 @@ class OpenAICompatibleBot:
|
||||
'default_top_p': float,
|
||||
'default_frequency_penalty': float,
|
||||
'default_presence_penalty': float,
|
||||
'api_type': str (optional, "auto" / "chat" / "responses"),
|
||||
}
|
||||
"""
|
||||
raise NotImplementedError("Subclasses must implement get_api_config()")
|
||||
@@ -112,9 +113,10 @@ class OpenAICompatibleBot:
|
||||
# Models like gpt-6-astra only support tool calling via the Responses
|
||||
# API (Chat Completions rejects tools with a non-"none" reasoning
|
||||
# effort, and Astra has no "none"). Route them through Responses and
|
||||
# translate the result back to Chat-Completions shape.
|
||||
# translate the result back to Chat-Completions shape. ``api_type``
|
||||
# lets endpoints that no longer serve /chat/completions force it.
|
||||
from models.openai import responses_adapter as responses_adapter
|
||||
if responses_adapter.is_responses_only_model(model_name):
|
||||
if responses_adapter.use_responses_api(model_name, api_config.get("api_type")):
|
||||
return self._call_with_tools_responses(
|
||||
model_name=model_name,
|
||||
messages=messages,
|
||||
@@ -189,14 +191,56 @@ class OpenAICompatibleBot:
|
||||
"status_code": 500
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _responses_reasoning_effort(cls, model_name, kwargs) -> Optional[str]:
|
||||
"""Pick the reasoning effort for a Responses request.
|
||||
|
||||
Responses-only models (gpt-6*) have no "none" tier, so they only get an
|
||||
explicit effort. For GPT-5.x / o-series the effort follows the thinking
|
||||
toggle: disabled -> "none" (same as the Chat Completions tool path),
|
||||
enabled -> the configured effort, or the model default when unset.
|
||||
"""
|
||||
from models.openai import responses_adapter
|
||||
|
||||
effort = kwargs.get("reasoning_effort")
|
||||
if responses_adapter.is_responses_only_model(model_name):
|
||||
return effort
|
||||
thinking = kwargs.get("thinking")
|
||||
if isinstance(thinking, dict) and thinking.get("type") == "enabled":
|
||||
return effort
|
||||
if cls._is_gpt5_reasoning_model(model_name):
|
||||
return "none"
|
||||
return None
|
||||
|
||||
def _responses_as_chat_completion(self, *, model, messages, api_key=None,
|
||||
api_base=None, timeout=None, max_tokens=None) -> dict:
|
||||
"""Run a non-streaming, tool-less Responses request and return it in
|
||||
Chat-Completions shape. Raises ``OpenAIHTTPError`` like
|
||||
``chat_completions`` so callers keep their existing error handling."""
|
||||
from models.openai import responses_adapter
|
||||
|
||||
payload = responses_adapter.build_responses_payload(
|
||||
model=model,
|
||||
messages=messages,
|
||||
max_output_tokens=max_tokens,
|
||||
reasoning_effort=self._responses_reasoning_effort(model, {}),
|
||||
)
|
||||
response = self._get_http_client().responses(
|
||||
api_key=api_key, api_base=api_base, timeout=timeout,
|
||||
stream=False, **payload,
|
||||
)
|
||||
return responses_adapter.responses_to_chat_completion(response)
|
||||
|
||||
def _call_with_tools_responses(self, *, model_name, messages, tools, stream,
|
||||
api_config, kwargs):
|
||||
"""Tool-calling path for Responses-only models (e.g. gpt-6-astra).
|
||||
"""Tool-calling path over the Responses API.
|
||||
|
||||
Builds a Responses request from the already-converted OpenAI-shaped
|
||||
``messages`` / ``tools`` and translates the Responses output (sync) or
|
||||
SSE events (stream) back into Chat-Completions shape so the agent
|
||||
consumes it identically to the ``/chat/completions`` path.
|
||||
Used for Responses-only models (e.g. gpt-6-astra) and whenever
|
||||
``api_type`` is "responses". Builds a Responses request from the
|
||||
already-converted OpenAI-shaped ``messages`` / ``tools`` and translates
|
||||
the Responses output (sync) or SSE events (stream) back into
|
||||
Chat-Completions shape so the agent consumes it identically to the
|
||||
``/chat/completions`` path.
|
||||
"""
|
||||
from models.openai import responses_adapter
|
||||
|
||||
@@ -206,7 +250,7 @@ class OpenAICompatibleBot:
|
||||
tools=tools,
|
||||
tool_choice=kwargs.get("tool_choice", "auto") if tools else None,
|
||||
max_output_tokens=kwargs.get("max_tokens"),
|
||||
reasoning_effort=kwargs.get("reasoning_effort"),
|
||||
reasoning_effort=self._responses_reasoning_effort(model_name, kwargs),
|
||||
response_format=kwargs.get("response_format"),
|
||||
)
|
||||
api_key = api_config.get("api_key")
|
||||
@@ -511,15 +555,38 @@ class OpenAICompatibleBot:
|
||||
api_key = api_config.get("api_key", "")
|
||||
api_base = (api_config.get("api_base") or "https://api.openai.com/v1").rstrip("/")
|
||||
|
||||
messages = [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": question},
|
||||
{"type": "image_url", "image_url": {"url": image_url}},
|
||||
],
|
||||
}]
|
||||
|
||||
from models.openai import responses_adapter
|
||||
if responses_adapter.resolve_api_type(api_config.get("api_type")) == responses_adapter.API_TYPE_RESPONSES:
|
||||
try:
|
||||
data = self._responses_as_chat_completion(
|
||||
model=vision_model, messages=messages, api_key=api_key,
|
||||
api_base=api_base, timeout=180,
|
||||
)
|
||||
except OpenAIHTTPError as e:
|
||||
logger.error(f"[{self.__class__.__name__}] call_vision HTTP {e.status_code}: {e.message}")
|
||||
return {"error": True, "message": f"HTTP {e.status_code}: {e.message}"}
|
||||
usage = data.get("usage", {})
|
||||
return {
|
||||
"model": vision_model,
|
||||
"content": data["choices"][0]["message"].get("content") or "",
|
||||
"usage": {
|
||||
"prompt_tokens": usage.get("prompt_tokens", 0),
|
||||
"completion_tokens": usage.get("completion_tokens", 0),
|
||||
"total_tokens": usage.get("total_tokens", 0),
|
||||
},
|
||||
}
|
||||
|
||||
payload = {
|
||||
"model": vision_model,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": question},
|
||||
{"type": "image_url", "image_url": {"url": image_url}},
|
||||
],
|
||||
}],
|
||||
"messages": messages,
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
# encoding:utf-8
|
||||
"""Routing between /chat/completions and /responses via ``open_ai_api_type``."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
import config as config_module
|
||||
from config import Config
|
||||
from models.openai import responses_adapter
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def chat_completions(self, **kwargs):
|
||||
self.calls.append(("chat", kwargs))
|
||||
return {
|
||||
"choices": [{"message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
||||
}
|
||||
|
||||
def responses(self, **kwargs):
|
||||
self.calls.append(("responses", kwargs))
|
||||
return {
|
||||
"id": "resp_1",
|
||||
"model": kwargs.get("model"),
|
||||
"status": "completed",
|
||||
"output": [{"type": "message", "content": [{"type": "output_text", "text": "ok"}]}],
|
||||
"usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
|
||||
}
|
||||
|
||||
|
||||
def _make_bot(monkeypatch, api_type=None, bot_type="chatGPT", model="gpt-5.5"):
|
||||
cfg = {
|
||||
"bot_type": bot_type,
|
||||
"model": model,
|
||||
"open_ai_api_key": "sk-test",
|
||||
"open_ai_api_base": "https://relay.example.com/v1",
|
||||
"custom_providers": [{"id": "c1", "name": "c", "api_key": "k",
|
||||
"api_base": "https://custom.example.com/v1", "model": model}],
|
||||
}
|
||||
if api_type is not None:
|
||||
cfg["open_ai_api_type"] = api_type
|
||||
monkeypatch.setattr(config_module, "config", Config(cfg))
|
||||
|
||||
from models.chatgpt.chat_gpt_bot import ChatGPTBot
|
||||
bot = ChatGPTBot(bot_type)
|
||||
client = _FakeClient()
|
||||
bot._http_client = client
|
||||
return bot, client
|
||||
|
||||
|
||||
def _call_tools(bot, model="gpt-5.5", **kwargs):
|
||||
tools = [{"name": "read", "description": "read a file",
|
||||
"input_schema": {"type": "object", "properties": {}}}]
|
||||
return bot.call_with_tools(
|
||||
[{"role": "user", "content": "hi"}], tools=tools, stream=False, model=model, **kwargs,
|
||||
)
|
||||
|
||||
|
||||
def test_default_keeps_chat_completions_for_gpt5(monkeypatch):
|
||||
bot, client = _make_bot(monkeypatch)
|
||||
_call_tools(bot)
|
||||
kind, params = client.calls[0]
|
||||
assert kind == "chat"
|
||||
assert params["reasoning_effort"] == "none"
|
||||
|
||||
|
||||
def test_responses_mode_routes_gpt5_to_responses_without_thinking(monkeypatch):
|
||||
bot, client = _make_bot(monkeypatch, api_type="responses")
|
||||
result = _call_tools(bot, thinking={"type": "disabled"})
|
||||
kind, params = client.calls[0]
|
||||
assert kind == "responses"
|
||||
assert params["reasoning"] == {"effort": "none"}
|
||||
assert "temperature" not in params
|
||||
assert params["tools"][0]["name"] == "read"
|
||||
assert result["choices"][0]["message"]["content"] == "ok"
|
||||
|
||||
|
||||
def test_responses_mode_thinking_enabled_uses_model_default_effort(monkeypatch):
|
||||
bot, client = _make_bot(monkeypatch, api_type="responses")
|
||||
_call_tools(bot, thinking={"type": "enabled"})
|
||||
_, params = client.calls[0]
|
||||
assert "reasoning" not in params
|
||||
|
||||
|
||||
def test_responses_mode_thinking_enabled_forwards_configured_effort(monkeypatch):
|
||||
bot, client = _make_bot(monkeypatch, api_type="responses")
|
||||
_call_tools(bot, thinking={"type": "enabled"}, reasoning_effort="high")
|
||||
_, params = client.calls[0]
|
||||
assert params["reasoning"] == {"effort": "high"}
|
||||
|
||||
|
||||
def test_responses_mode_non_reasoning_model_sends_no_effort(monkeypatch):
|
||||
bot, client = _make_bot(monkeypatch, api_type="responses", model="gpt-4.1")
|
||||
_call_tools(bot, model="gpt-4.1", thinking={"type": "disabled"})
|
||||
kind, params = client.calls[0]
|
||||
assert kind == "responses"
|
||||
assert "reasoning" not in params
|
||||
|
||||
|
||||
def test_auto_mode_gpt6_behavior_unchanged(monkeypatch):
|
||||
bot, client = _make_bot(monkeypatch, model="gpt-6-astra")
|
||||
_call_tools(bot, model="gpt-6-astra", thinking={"type": "disabled"})
|
||||
kind, params = client.calls[0]
|
||||
assert kind == "responses"
|
||||
assert "reasoning" not in params
|
||||
|
||||
|
||||
def test_chat_mode_forces_chat_completions(monkeypatch):
|
||||
bot, client = _make_bot(monkeypatch, api_type="chat", model="gpt-6-astra")
|
||||
_call_tools(bot, model="gpt-6-astra")
|
||||
assert client.calls[0][0] == "chat"
|
||||
|
||||
|
||||
def test_custom_provider_ignores_openai_api_type(monkeypatch):
|
||||
bot, client = _make_bot(monkeypatch, api_type="responses", bot_type="custom:c1")
|
||||
_call_tools(bot)
|
||||
assert client.calls[0][0] == "chat"
|
||||
|
||||
|
||||
def test_reply_text_uses_responses_when_configured(monkeypatch):
|
||||
from models.session_manager import Session
|
||||
|
||||
bot, client = _make_bot(monkeypatch, api_type="responses")
|
||||
session = Session("s1", system_prompt="")
|
||||
session.messages = [{"role": "user", "content": "title please"}]
|
||||
result = bot.reply_text(session)
|
||||
kind, params = client.calls[0]
|
||||
assert kind == "responses"
|
||||
assert params["model"] == "gpt-5.5"
|
||||
assert params["reasoning"] == {"effort": "none"}
|
||||
assert result["content"] == "ok"
|
||||
assert result["completion_tokens"] == 1
|
||||
|
||||
|
||||
def test_reply_text_default_keeps_chat_completions(monkeypatch):
|
||||
from models.session_manager import Session
|
||||
|
||||
bot, client = _make_bot(monkeypatch)
|
||||
session = Session("s1", system_prompt="")
|
||||
session.messages = [{"role": "user", "content": "title please"}]
|
||||
bot.reply_text(session)
|
||||
assert client.calls[0][0] == "chat"
|
||||
|
||||
|
||||
def test_azure_ignores_openai_api_type():
|
||||
from models.chatgpt.chat_gpt_bot import AzureChatGPTBot
|
||||
|
||||
bot = AzureChatGPTBot.__new__(AzureChatGPTBot)
|
||||
assert bot._openai_api_type() == responses_adapter.API_TYPE_AUTO
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value, expected", [
|
||||
(None, "auto"), ("", "auto"), ("RESPONSES", "responses"),
|
||||
("chat", "chat"), ("bogus", "auto"),
|
||||
])
|
||||
def test_resolve_api_type(value, expected):
|
||||
assert responses_adapter.resolve_api_type(value) == expected
|
||||
|
||||
|
||||
def test_normalize_effort_only_clamps_responses_only_models():
|
||||
assert responses_adapter.normalize_effort("none", "gpt-5.5") == "none"
|
||||
assert responses_adapter.normalize_effort("none", "gpt-6-astra") == "low"
|
||||
assert responses_adapter.normalize_effort("minimal") == "low"
|
||||
Reference in new issue
Block a user