feat: add gpt-6-astra model with Responses API tool calling

Route gpt-6-astra tool calls through the Responses API without depending on
the openai SDK, translating requests/streaming back to the chat-completion
shape consumed downstream. Also surface it in the console dropdown, README
and docs, and reorder the recommended list (gpt group ahead of gemini).

Co-authored-by: cowagent <cow@cowagent.ai>
This commit is contained in:
zhayujieandcowagent committed 2026-09-10 12:03:03 +08:00
1 parent f4dbae8f49
commit ead145a7ae
15 files changed
+541 -21

No files matched your search

+2 -2
View File
@@ -132,8 +132,8 @@ CowAgent supports all mainstream LLM providers. **Chat, vision, image generation
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [Claude](https://docs.cowagent.ai/models/claude) | claude-opus-5 / fable-5.1 | ✅ | ✅ | | | | |
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.6 series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.7-flash | ✅ | ✅ | ✅ | | | |
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-6-astra / gpt-5.6 series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.8-flash | ✅ | ✅ | ✅ | | | |
| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
| [GLM](https://docs.cowagent.ai/models/glm) | glm-5.3-flash, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.8-flash | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
+6 -6
View File
@@ -2728,8 +2728,8 @@ class ConfigHandler:
const.MINIMAX_M3, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7,
# claude-opus-5 is the Claude default; claude-sonnet-5 / claude-fable-5 follow right after it.
const.CLAUDE_OPUS_5, const.CLAUDE_SONNET_5, const.CLAUDE_FABLE_5_1, const.CLAUDE_FABLE_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS,
const.GEMINI_37_FLASH, const.GEMINI_36_FLASH, const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE,
const.GPT_56_LUNA, const.GPT_56_TERRA, const.GPT_56_SOL, const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
const.GPT_56_LUNA, const.GPT_6_ASTRA, const.GPT_56_TERRA, const.GPT_56_SOL, const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
const.GEMINI_38_FLASH, const.GEMINI_37_FLASH, const.GEMINI_36_FLASH, const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE,
const.GLM_5_3_FLASH, const.GLM_5_3, const.GLM_5_2, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
const.QWEN38_FLASH, const.QWEN38_MAX, const.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS,
const.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_CODE,
@@ -2772,7 +2772,7 @@ class ConfigHandler:
"api_base_key": "open_ai_api_base",
"api_base_default": "https://api.openai.com/v1",
"api_base_placeholder": _PLACEHOLDER_V1,
"models": [const.GPT_56_LUNA, const.GPT_56_TERRA, const.GPT_56_SOL, const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o],
"models": [const.GPT_56_LUNA, const.GPT_6_ASTRA, const.GPT_56_TERRA, const.GPT_56_SOL, const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o],
}),
("gemini", {
"label": "Gemini",
@@ -2780,7 +2780,7 @@ class ConfigHandler:
"api_base_key": "gemini_api_base",
"api_base_default": "https://generativelanguage.googleapis.com",
"api_base_placeholder": _PLACEHOLDER_GEMINI,
"models": [const.GEMINI_37_FLASH, const.GEMINI_36_FLASH, const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE],
"models": [const.GEMINI_38_FLASH, const.GEMINI_37_FLASH, const.GEMINI_36_FLASH, const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE],
}),
("minimax", {
"label": "MiniMax",
@@ -3476,7 +3476,7 @@ class ModelsHandler:
# entry is the auto-picked vision model, and image understanding does
# not justify the Opus price.
"claudeAPI": [const.CLAUDE_SONNET_5, const.CLAUDE_OPUS_5, const.CLAUDE_FABLE_5_1, const.CLAUDE_FABLE_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
"gemini": [const.GEMINI_37_FLASH, const.GEMINI_36_FLASH, const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE],
"gemini": [const.GEMINI_38_FLASH, const.GEMINI_37_FLASH, const.GEMINI_36_FLASH, const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE],
"qianfan": [const.ERNIE_45_TURBO_VL],
# glm-5.3-flash is natively multimodal and dispatched as-is; the
# text-only chat models (glm-5.2, glm-5-turbo, etc.) fall back to the
@@ -3867,7 +3867,7 @@ class ModelsHandler:
("doubao", "ark_api_key", const.DOUBAO_SEED_2_PRO),
("dashscope", "dashscope_api_key", const.QWEN37_PLUS),
("claudeAPI", "claude_api_key", const.CLAUDE_SONNET_5),
("gemini", "gemini_api_key", const.GEMINI_37_FLASH),
("gemini", "gemini_api_key", const.GEMINI_38_FLASH),
("qianfan", "qianfan_api_key", const.ERNIE_45_TURBO_VL),
("zhipu", "zhipu_ai_api_key", const.GLM_5V_TURBO),
("minimax", "minimax_api_key", const.MINIMAX_TEXT_01),
+3 -1
View File
@@ -56,6 +56,7 @@ GEMINI_31_FLASH_LITE_PRE = "gemini-3.1-flash-lite-preview" # Gemini 3.1 Flash L
GEMINI_35_FLASH = "gemini-3.5-flash" # Gemini 3.5 Flash
GEMINI_36_FLASH = "gemini-3.6-flash" # Gemini 3.6 Flash
GEMINI_37_FLASH = "gemini-3.7-flash" # Gemini 3.7 Flash - Agent recommended model (default)
GEMINI_38_FLASH = "gemini-3.8-flash" # Gemini 3.8 Flash - latest flagship
# OpenAI
GPT35 = "gpt-3.5-turbo"
@@ -85,6 +86,7 @@ GPT_54_MINI = "gpt-5.4-mini"
GPT_54_NANO = "gpt-5.4-nano"
GPT_55 = "gpt-5.5" # GPT-5.5 - top-tier (expensive), not default
GPT_56_LUNA = "gpt-5.6-luna" # GPT-5.6 Luna - default flagship model for GPT
GPT_6_ASTRA = "gpt-6-astra" # GPT-6 Astra - most intelligent flagship (Responses API, higher cost)
GPT_56_TERRA = "gpt-5.6-terra" # GPT-5.6 Terra
GPT_56_SOL = "gpt-5.6-sol" # GPT-5.6 Sol - highest intelligence, higher latency
O1 = "o1-preview"
@@ -218,7 +220,7 @@ MODEL_LIST = [
"claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet",
# Gemini
GEMINI_37_FLASH, GEMINI_36_FLASH, GEMINI_35_FLASH, GEMINI_31_FLASH_LITE_PRE, GEMINI_31_PRO_PRE, GEMINI_3_PRO_PRE, GEMINI_3_FLASH_PRE, GEMINI_25_PRO_PRE, GEMINI_25_FLASH_PRE,
GEMINI_38_FLASH, GEMINI_37_FLASH, GEMINI_36_FLASH, GEMINI_35_FLASH, GEMINI_31_FLASH_LITE_PRE, GEMINI_31_PRO_PRE, GEMINI_3_PRO_PRE, GEMINI_3_FLASH_PRE, GEMINI_25_PRO_PRE, GEMINI_25_FLASH_PRE,
GEMINI_20_FLASH, GEMINI_20_flash_exp, GEMINI_15_PRO, GEMINI_15_flash, GEMINI_PRO, GEMINI,
# OpenAI
+2 -2
View File
@@ -132,8 +132,8 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
| [DeepSeek](https://docs.cowagent.ai/ja/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [Claude](https://docs.cowagent.ai/ja/models/claude) | claude-opus-5 / fable-5.1 | ✅ | ✅ | | | | |
| [OpenAI](https://docs.cowagent.ai/ja/models/openai) | gpt-5.6 シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Gemini](https://docs.cowagent.ai/ja/models/gemini) | gemini-3.7-flash | ✅ | ✅ | ✅ | | | |
| [OpenAI](https://docs.cowagent.ai/ja/models/openai) | gpt-6-astra / gpt-5.6 シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Gemini](https://docs.cowagent.ai/ja/models/gemini) | gemini-3.8-flash | ✅ | ✅ | ✅ | | | |
| [MiniMax](https://docs.cowagent.ai/ja/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
| [GLM](https://docs.cowagent.ai/ja/models/glm) | glm-5.3-flash、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [Qwen](https://docs.cowagent.ai/ja/models/qwen) | qwen3.8-flash | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
+1 -1
View File
@@ -22,7 +22,7 @@ OpenAI は最も広範な機能をカバーするベンダーで、テキスト
| パラメータ | 説明 |
| --- | --- |
| `model` | OpenAI API の [model パラメータ](https://platform.openai.com/docs/models) と同じです。`gpt-5.6-luna`、`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-5.4` に変更可能 |
| `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` | 任意。サードパーティのプロキシに接続するために変更可能 |
| `bot_type` | OpenAI 公式モデルを使用する場合は不要。互換プロトコルでベンダーモデルに接続する場合は `openai` に設定 |
+1 -1
View File
@@ -64,7 +64,7 @@ Thanks @chimyves (#3087)、@sufan721 (#3079)
## 🤖 モデル機能
- **新モデル追加**:`claude-fable-5-1`、`qwen3.8-flash`、`glm-5.3-flash`、および視覚モデル `deepseek-v4-flash-vision-exp` に対応しました(Thanks @a1094174619 #3094)。
- **新モデル追加**:フラッグシップモデル `gpt-6-astra`、`claude-fable-5-1`、`qwen3.8-flash`、`glm-5.3-flash`、`gemini-3.8-flash`、および視覚モデル `deepseek-v4-flash-vision-exp` に対応しました(Thanks @a1094174619 #3094)。
- **モデルのフォールバック**:「モデル設定 - メインモデル」でフォールバックモデルを設定でき、メインモデルが失敗した際に自動で切り替わるため、単一プロバイダーの障害による中断を防げます。Thanks @alanyz106 (#3099)
- **検索プロバイダーを 6 種追加**:
- **AnySearch**(匿名モードに対応)。Thanks @WangPan59 (#3083)
+1 -1
View File
@@ -22,7 +22,7 @@ OpenAI offers the most complete coverage and can simultaneously serve text chat,
| Parameter | Description |
| --- | --- |
| `model` | Same as OpenAI's [model parameter](https://platform.openai.com/docs/models); supports `gpt-5.6-luna`, `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`; use `gpt-5.4` for better cost-efficiency |
| `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 |
| `bot_type` | Not required when using OpenAI's official models; set to `openai` when accessing other providers via the compatible protocol |
+1 -1
View File
@@ -64,7 +64,7 @@ Thanks @chimyves (#3087), @sufan721 (#3079)
## 🤖 Model Capabilities
- **New models**: added `claude-fable-5-1`, `qwen3.8-flash`, `glm-5.3-flash`, and the vision model `deepseek-v4-flash-vision-exp` (Thanks @a1094174619 #3094).
- **New models**: added the flagship `gpt-6-astra`, `claude-fable-5-1`, `qwen3.8-flash`, `glm-5.3-flash`, `gemini-3.8-flash`, and the vision model `deepseek-v4-flash-vision-exp` (Thanks @a1094174619 #3094).
- **Model fallback**: configure a fallback model under Model Config > Primary Model, which takes over automatically when the primary model fails, preventing interruptions from a single provider outage. Thanks @alanyz106 (#3099)
- **Six new search providers**:
- **AnySearch** (supports anonymous mode). Thanks @WangPan59 (#3083)
+2 -2
View File
@@ -132,8 +132,8 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-opus-5 / fable-5.1 | ✅ | ✅ | | | | |
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.6 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.7-flash | ✅ | ✅ | ✅ | | | |
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-6-astra / gpt-5.6 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.8-flash | ✅ | ✅ | ✅ | | | |
| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
| [智譜 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.3-flash、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [通義千問](https://docs.cowagent.ai/zh/models/qwen) | qwen3.8-flash | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
+2 -2
View File
@@ -132,8 +132,8 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-opus-5 / fable-5.1 | ✅ | ✅ | | | | |
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.6 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.7-flash | ✅ | ✅ | ✅ | | | |
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-6-astra / gpt-5.6 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.8-flash | ✅ | ✅ | ✅ | | | |
| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
| [智谱 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.3-flash、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [通义千问](https://docs.cowagent.ai/zh/models/qwen) | qwen3.8-flash | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
+1 -1
View File
@@ -22,7 +22,7 @@ OpenAI 是覆盖最完整的厂商,可同时承担文本对话、视觉理解
| 参数 | 说明 |
| --- | --- |
| `model` | 与 OpenAI 接口的 [model 参数](https://platform.openai.com/docs/models) 一致,支持 `gpt-5.6-luna`、`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-5.4` |
| `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` | 可选,修改可接入第三方代理 |
| `bot_type` | 使用 OpenAI 官方模型时无需填写;通过兼容协议接入厂商模型时需设为 `openai` |
+1 -1
View File
@@ -64,7 +64,7 @@ Thanks @chimyves (#3087)、@sufan721 (#3079)
## 🤖 模型能力
- **新增模型**:接入 `claude-fable-5-1`、`qwen3.8-flash`、`glm-5.3-flash`,以及视觉模型 `deepseek-v4-flash-vision-exp`(Thanks @a1094174619 #3094)。
- **新增模型**:接入 `gpt-6-astra`、`claude-fable-5-1`、`qwen3.8-flash`、`glm-5.3-flash`、`gemini-3.8-flash`,以及视觉模型 `deepseek-v4-flash-vision-exp`(Thanks @a1094174619 #3094)。
- **模型故障回退**:在「模型配置-主模型」处可配置「兜底模型」,当主模型失败时自动切换,避免因单一厂商故障而中断。Thanks @alanyz106 (#3099)
- **新增 6 个搜索工具厂商**:
- **AnySearch**(支持匿名模式)。Thanks @WangPan59 (#3083)
+39
View File
@@ -172,6 +172,45 @@ class OpenAIHTTPClient:
stream=stream,
)
def responses(
self,
*,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
timeout: Optional[float] = None,
proxy: Optional[str] = None,
extra_headers: Optional[Dict[str, str]] = None,
extra_query: Optional[Dict[str, str]] = None,
path: str = "/responses",
stream: bool = False,
**payload,
):
"""POST /responses (OpenAI Responses API).
Required for models like gpt-6-astra whose tool calling is only
supported on Responses (Chat Completions cannot do tool calling with a
non-``none`` reasoning effort, and Astra does not support ``none``).
Non-streaming returns the raw Responses ``dict``. Streaming returns a
generator yielding parsed SSE event ``dict`` objects (each carries a
``type`` such as ``response.output_text.delta``); the caller is
responsible for translating them into whatever shape it needs. On
error during streaming a single ``{"error": ..., "status_code": ...}``
chunk is yielded, matching :meth:`chat_completions`.
"""
payload["stream"] = stream
return self._request(
path=path,
payload=payload,
api_key=api_key,
api_base=api_base,
timeout=timeout,
proxy=proxy,
extra_headers=extra_headers,
extra_query=extra_query,
stream=stream,
)
def completions(
self,
*,
+401
View File
@@ -0,0 +1,401 @@
# encoding:utf-8
"""
OpenAI Responses API adapter.
Some models (e.g. ``gpt-6-astra``) only support tool calling through the
Responses API: on Chat Completions, tool calling is rejected unless the
reasoning effort is ``none``, and Astra does not support ``none``. To keep the
rest of the agent unchanged we translate at the edges:
- request: Chat-Completions-shaped ``messages`` / ``tools`` (the shape the
agent already produces) -> Responses ``input`` items / tools.
- response: Responses output / SSE events -> Chat-Completions-shaped ``dict``
responses and stream chunks, so ``agent/protocol/agent_stream.py``
consumes them exactly as it consumes ``/chat/completions`` output:
chunk["choices"][0]["delta"]["content" | "tool_calls" | ...]
chunk["choices"][0]["finish_reason"]
chunk["usage"] = {prompt_tokens, completion_tokens, total_tokens}
This module has no dependency on the ``openai`` SDK — it only shapes plain
dicts consumed/produced by ``OpenAIHTTPClient``.
"""
import json
from typing import Any, Dict, Generator, List, Optional
from common.log import logger
# Models whose tool calling requires the Responses API. Matched by prefix so
# dated snapshots (gpt-6-astra-2026-..) and future gpt-6 variants are covered.
_RESPONSES_ONLY_PREFIXES = ("gpt-6",)
# gpt-6-astra does not support reasoning effort "none"/"minimal"; clamp to the
# lowest it accepts so a config carried over from gpt-5.x does not 400.
_MIN_EFFORT_FALLBACK = "low"
_UNSUPPORTED_EFFORTS = {"none", "minimal"}
def is_responses_only_model(model_name: str) -> bool:
"""Whether the model must use the Responses API for tool calling."""
if not model_name or not isinstance(model_name, str):
return False
name = model_name.strip().lower()
return name.startswith(_RESPONSES_ONLY_PREFIXES)
def normalize_effort(effort: Optional[str]) -> Optional[str]:
"""Map a reasoning effort to one Responses-only models accept."""
if not effort:
return None
value = str(effort).strip().lower()
if value in _UNSUPPORTED_EFFORTS:
return _MIN_EFFORT_FALLBACK
return value
# --------------------------------------------------------------------------- #
# Request translation: Chat Completions -> Responses
# --------------------------------------------------------------------------- #
def _content_to_input_parts(content: Any, role: str) -> Any:
"""Convert a Chat-Completions message content into Responses content.
Strings pass through. A list of blocks is converted to Responses input
parts (``input_text`` / ``input_image``); assistant text uses
``output_text`` per the Responses schema.
"""
if isinstance(content, str):
return content
if not isinstance(content, list):
return "" if content is None else str(content)
text_type = "output_text" if role == "assistant" else "input_text"
parts: List[Dict[str, Any]] = []
for item in content:
if not isinstance(item, dict):
continue
itype = item.get("type")
if itype in ("text", "input_text", "output_text"):
text = item.get("text") or ""
if text:
parts.append({"type": text_type, "text": text})
elif itype in ("image_url", "input_image") and role == "user":
image = item.get("image_url")
url = image.get("url") if isinstance(image, dict) else image
if url:
parts.append({"type": "input_image", "image_url": url})
return parts
def messages_to_input(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Convert Chat-Completions messages into Responses ``input`` items.
Handles the tool-calling round trip:
- assistant ``tool_calls`` -> ``function_call`` items (internally tagged).
- ``role: tool`` results -> ``function_call_output`` items linked by
``call_id``.
Plain system/user/assistant text becomes ``message`` items.
"""
input_items: List[Dict[str, Any]] = []
for msg in messages or []:
if not isinstance(msg, dict):
continue
role = msg.get("role")
if role == "tool":
input_items.append({
"type": "function_call_output",
"call_id": msg.get("tool_call_id") or "",
"output": _tool_output_text(msg.get("content")),
})
continue
if role == "assistant" and msg.get("tool_calls"):
content = msg.get("content")
if content:
input_items.append({
"role": "assistant",
"content": _content_to_input_parts(content, "assistant"),
})
for tc in msg.get("tool_calls") or []:
fn = tc.get("function") or {}
input_items.append({
"type": "function_call",
"call_id": tc.get("id") or "",
"name": fn.get("name") or "",
"arguments": fn.get("arguments") or "{}",
})
continue
role = role if role in ("system", "developer", "user", "assistant") else "user"
content = _content_to_input_parts(msg.get("content"), role)
if content == "" or content == []:
continue
input_items.append({"role": role, "content": content})
return input_items
def _tool_output_text(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
return " ".join(
str(b.get("text", "")) for b in content
if isinstance(b, dict) and b.get("type") in ("text", "output_text", None)
)
if content is None:
return ""
return json.dumps(content, ensure_ascii=False)
def tools_to_responses(tools: Optional[List[Dict[str, Any]]]) -> Optional[List[Dict[str, Any]]]:
"""Convert Chat-Completions tools (externally tagged) into Responses tools
(internally tagged): ``{type:function, function:{name,...}}`` ->
``{type:function, name, description, parameters}``."""
if not tools:
return None
converted: List[Dict[str, Any]] = []
for tool in tools:
if not isinstance(tool, dict):
continue
fn = tool.get("function")
if isinstance(fn, dict):
entry = {
"type": "function",
"name": fn.get("name"),
"description": fn.get("description"),
"parameters": fn.get("parameters") or {},
}
else:
# Already internally tagged / Claude-shaped fallback.
entry = {
"type": "function",
"name": tool.get("name"),
"description": tool.get("description"),
"parameters": tool.get("parameters") or tool.get("input_schema") or {},
}
converted.append({k: v for k, v in entry.items() if v is not None})
return converted or None
def build_responses_payload(
*,
model: str,
messages: List[Dict[str, Any]],
tools: Optional[List[Dict[str, Any]]] = None,
tool_choice: Optional[Any] = None,
max_output_tokens: Optional[int] = None,
reasoning_effort: Optional[str] = None,
response_format: Optional[Dict[str, Any]] = None,
store: bool = False,
) -> Dict[str, Any]:
"""Assemble a Responses API request body.
Deliberately omits ``temperature`` / ``top_p`` / penalties: Responses-only
reasoning models reject them.
"""
payload: Dict[str, Any] = {
"model": model,
"input": messages_to_input(messages),
"store": store,
}
r_tools = tools_to_responses(tools)
if r_tools:
payload["tools"] = r_tools
payload["tool_choice"] = tool_choice or "auto"
if max_output_tokens:
payload["max_output_tokens"] = max_output_tokens
effort = normalize_effort(reasoning_effort)
if effort:
payload["reasoning"] = {"effort": effort}
if response_format and response_format.get("type") == "json_object":
payload["text"] = {"format": {"type": "json_object"}}
return payload
# --------------------------------------------------------------------------- #
# Response translation: Responses -> Chat Completions
# --------------------------------------------------------------------------- #
def _cc_usage(usage: Any) -> Optional[Dict[str, Any]]:
if not isinstance(usage, dict):
return None
return {
"prompt_tokens": usage.get("input_tokens") or 0,
"completion_tokens": usage.get("output_tokens") or 0,
"total_tokens": usage.get("total_tokens") or 0,
}
def _status_to_finish_reason(response: Dict[str, Any], has_tool_calls: bool) -> str:
status = response.get("status")
if has_tool_calls:
return "tool_calls"
if status == "incomplete":
details = response.get("incomplete_details") or {}
if details.get("reason") == "max_output_tokens":
return "length"
return "stop"
def responses_to_chat_completion(response: Dict[str, Any]) -> Dict[str, Any]:
"""Convert a non-streaming Responses object into a Chat-Completions dict."""
text_parts: List[str] = []
tool_calls: List[Dict[str, Any]] = []
for item in response.get("output") or []:
if not isinstance(item, dict):
continue
itype = item.get("type")
if itype == "message":
for part in item.get("content") or []:
if isinstance(part, dict) and part.get("type") == "output_text":
text_parts.append(str(part.get("text") or ""))
elif itype == "function_call":
tool_calls.append({
"id": item.get("call_id") or item.get("id") or "",
"type": "function",
"function": {
"name": item.get("name") or "",
"arguments": item.get("arguments") or "{}",
},
})
message: Dict[str, Any] = {
"role": "assistant",
"content": "".join(text_parts) or None,
}
if tool_calls:
message["tool_calls"] = tool_calls
return {
"id": response.get("id"),
"object": "chat.completion",
"model": response.get("model"),
"choices": [{
"index": 0,
"message": message,
"finish_reason": _status_to_finish_reason(response, bool(tool_calls)),
}],
"usage": _cc_usage(response.get("usage")) or {
"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0,
},
}
def _text_chunk(model: Optional[str], delta: Dict[str, Any],
finish_reason: Optional[str] = None) -> Dict[str, Any]:
return {
"object": "chat.completion.chunk",
"model": model,
"choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
}
def responses_stream_to_chat_chunks(
events: Generator[Dict[str, Any], None, None],
model: Optional[str] = None,
) -> Generator[Dict[str, Any], None, None]:
"""Translate a Responses SSE event stream into Chat-Completions chunks.
Emits the shapes ``agent_stream`` already handles:
- text: delta.content
- reasoning: delta.reasoning_content
- tool calls: delta.tool_calls = [{index, id, function:{name, arguments}}]
- final: finish_reason + top-level usage
- errors: {"error": {...}, "message": ..., "status_code": ...}
Function-call arguments are streamed by Responses as
``response.function_call_arguments.delta`` events keyed by ``output_index``;
we map each output index to a stable ``tool_calls`` index and forward the
id/name once (on the ``output_item.added`` event) then argument deltas.
"""
# output_index -> tool_calls stream index
tool_index_map: Dict[int, int] = {}
next_tool_index = 0
saw_tool_call = False
finished = False
for event in events:
if not isinstance(event, dict):
continue
# Error chunk forwarded by OpenAIHTTPClient._stream_chat.
if event.get("error") and "type" not in event:
yield event
return
etype = event.get("type")
if etype == "response.output_text.delta":
delta = event.get("delta")
if delta:
yield _text_chunk(model, {"content": str(delta)})
continue
if etype in ("response.reasoning_summary_text.delta", "response.reasoning_text.delta"):
delta = event.get("delta")
if delta:
yield _text_chunk(model, {"reasoning_content": str(delta)})
continue
if etype == "response.output_item.added":
item = event.get("item") or {}
if item.get("type") == "function_call":
out_idx = event.get("output_index", 0)
if out_idx not in tool_index_map:
tool_index_map[out_idx] = next_tool_index
next_tool_index += 1
saw_tool_call = True
yield _text_chunk(model, {"tool_calls": [{
"index": tool_index_map[out_idx],
"id": item.get("call_id") or item.get("id") or "",
"type": "function",
"function": {"name": item.get("name") or "", "arguments": ""},
}]})
continue
if etype == "response.function_call_arguments.delta":
out_idx = event.get("output_index", 0)
if out_idx not in tool_index_map:
tool_index_map[out_idx] = next_tool_index
next_tool_index += 1
saw_tool_call = True
delta = event.get("delta")
if delta:
yield _text_chunk(model, {"tool_calls": [{
"index": tool_index_map[out_idx],
"function": {"arguments": str(delta)},
}]})
continue
if etype in ("response.completed", "response.incomplete"):
response = event.get("response") or {}
finish_reason = _status_to_finish_reason(response, saw_tool_call)
usage = _cc_usage(response.get("usage"))
final_chunk = _text_chunk(model or response.get("model"), {}, finish_reason)
if usage:
final_chunk["usage"] = usage
yield final_chunk
finished = True
continue
if etype in ("response.failed", "error"):
response = event.get("response") or {}
err = event.get("error") or response.get("error") or {}
message = err.get("message") if isinstance(err, dict) else str(err)
code = err.get("code") if isinstance(err, dict) else ""
logger.error(f"[Responses] stream error: {message} (code={code})")
yield {
"error": {"message": message or "Responses stream error",
"code": code or "", "type": err.get("type", "") if isinstance(err, dict) else ""},
"message": message or "Responses stream error",
"status_code": 500,
}
return
# Some proxies omit an explicit completed event; make sure the agent sees a
# terminal finish_reason so it doesn't treat the turn as truncated.
if not finished:
yield _text_chunk(model, {}, "tool_calls" if saw_tool_call else "stop")
+78
View File
@@ -108,6 +108,21 @@ class OpenAICompatibleBot:
# Build request parameters
model_name = kwargs.get("model", api_config.get('model', 'gpt-5.4'))
# 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.
from models.openai import responses_adapter as responses_adapter
if responses_adapter.is_responses_only_model(model_name):
return self._call_with_tools_responses(
model_name=model_name,
messages=messages,
tools=tools,
stream=stream,
api_config=api_config,
kwargs=kwargs,
)
request_params = {
"model": model_name,
"messages": messages,
@@ -174,6 +189,69 @@ class OpenAICompatibleBot:
"status_code": 500
}
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).
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
payload = responses_adapter.build_responses_payload(
model=model_name,
messages=messages,
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"),
response_format=kwargs.get("response_format"),
)
api_key = api_config.get("api_key")
api_base = api_config.get("api_base")
timeout = kwargs.get("request_timeout") or kwargs.get("timeout")
if stream:
return self._handle_responses_stream(payload, api_key, api_base, timeout, model_name)
return self._handle_responses_sync(payload, api_key, api_base, timeout)
def _handle_responses_sync(self, payload, api_key, api_base, timeout):
from models.openai import responses_adapter
try:
client = self._get_http_client()
response = client.responses(
api_key=api_key, api_base=api_base, timeout=timeout,
stream=False, **payload,
)
return responses_adapter.responses_to_chat_completion(response)
except OpenAIHTTPError as e:
logger.error(f"[{self.__class__.__name__}] responses sync error: "
f"HTTP {e.status_code}: {e.message}")
return {"error": True, "message": e.message, "status_code": e.status_code or 500}
except Exception as e:
logger.error(f"[{self.__class__.__name__}] responses sync error: {e}")
return {"error": True, "message": str(e), "status_code": 500}
def _handle_responses_stream(self, payload, api_key, api_base, timeout, model_name):
from models.openai import responses_adapter
try:
client = self._get_http_client()
events = client.responses(
api_key=api_key, api_base=api_base, timeout=timeout,
stream=True, **payload,
)
for chunk in responses_adapter.responses_stream_to_chat_chunks(events, model_name):
yield chunk
except OpenAIHTTPError as e:
logger.error(f"[{self.__class__.__name__}] responses stream error: "
f"HTTP {e.status_code}: {e.message}")
yield {"error": True, "message": e.message, "status_code": e.status_code or 500}
except Exception as e:
logger.error(f"[{self.__class__.__name__}] responses stream error: {e}")
yield {"error": True, "message": str(e), "status_code": 500}
def _get_http_client(self) -> OpenAIHTTPClient:
"""Build an HTTP client honoring the global proxy config.