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:
zhayujieandcowagent committed 2026-09-28 16:16:37 +08:00
1 parent 59c0da12e3
commit 7fe7163593
10 files changed
+347 -18

No files matched your search

+23 -2
View File
@@ -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:
+2
View File
@@ -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
+1
View File
@@ -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` に設定 |
## 画像理解
+1
View File
@@ -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
+1
View File
@@ -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` |
## 图像理解
+37
View File
@@ -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,6 +248,15 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
logger.info(f"[CHATGPT] Calling vision API with model: {model}")
# Call OpenAI-compatible API via HTTP
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,
@@ -279,6 +301,16 @@ 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)
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,
@@ -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``
+1
View File
@@ -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:
+33 -4
View File
@@ -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":
+79 -12
View File
@@ -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("/")
payload = {
"model": vision_model,
"messages": [{
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": messages,
}
headers = {
"Authorization": f"Bearer {api_key}",
+169
View File
@@ -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"