Compare commits
4
Commits
4e2bab3032
...
v0.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2252678dd | ||
|
|
7002e42c06 | ||
|
|
da7b29d2e3 | ||
|
|
99b551401e |
@@ -2,6 +2,24 @@
|
|||||||
|
|
||||||
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。
|
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。
|
||||||
|
|
||||||
|
## [0.7.0]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- AI 网关运行时设置扩展(`GET` / `PUT /api/v1/ai-settings`,持久化、即时生效):
|
||||||
|
- Responses 流式保险丝:开关 + 阈值(KB,1..1024),超阈值的流式请求预防性改非流式上游并合成最小 SSE
|
||||||
|
- grok 服务端搜索工具默认注入:`xai.` 前缀模型的 Responses 请求按开关默认注入 `web_search` / `x_search`,请求 `tools` 已含同名工具(任意参数形态)时不覆盖,注入动作记服务端日志
|
||||||
|
- 聚合模型目录端点 `GET /api/v1/ai-model-catalog`:启用渠道去重、含能力字段(空能力归一 CHAT),与模型列表同口径(随「过滤弃用」开关),供设置页黑名单添加弹窗使用
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Responses 流式保险丝触发口径由「完整请求体 > 76KB(环境变量 `AI_RESP_STREAM_UPGRADE_KB`)」改为「`instructions` + `tools` 原始字节合计超阈值(默认开、60KB,设置页可调)」:复测证实约 82KB 的纯体积断流已由上游修复,而 `instructions`+`tools` 合计 >≈64.5KB 的流式静默断流仍存在(`input` 不计入);环境变量随之废弃
|
||||||
|
- 升级 oci-go-sdk 到 v65.121.0
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 修复多网卡实例的列表 / 详情地址字段随机:并发填充改以主网卡为准(主卡未返回前允许先到网卡兜底,主卡到达后覆盖并锁定),公网 IP / 私网 IP / IPv6 / 子网恒为主网卡数据
|
||||||
|
|
||||||
## [0.6.1]
|
## [0.6.1]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
v0.6.0
|
v0.7.0
|
||||||
|
|||||||
@@ -6,12 +6,14 @@
|
|||||||
|
|
||||||
**自托管的 OCI 多租户管理面板与 GenAI 兼容网关**
|
**自托管的 OCI 多租户管理面板与 GenAI 兼容网关**
|
||||||
|
|
||||||

|
[](https://github.com/wangdefaa/oci-portal/releases/latest)
|
||||||

|
[](go.mod)
|
||||||

|
[](docker-compose.yml)
|
||||||

|
[](LICENSE)
|
||||||
|
|
||||||
[快速开始](#快速开始) · [核心能力](#核心能力) · [生产部署](#生产部署) · [AI 网关](#ai-网关) · [开发](#开发)
|
[界面预览](#界面预览) · [核心能力](#核心能力) · [快速开始](#快速开始) · [生产部署](#生产部署) · [AI 网关](#ai-网关) · [开发](#开发)
|
||||||
|
|
||||||
|
[AI 网关文档](docs/AI网关.md) · [OpenAPI](docs/swagger.yaml) · [更新日志](CHANGELOG.md) · [前端仓库](https://github.com/wangdefaa/oci-portal-dash)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -21,27 +23,36 @@ OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和
|
|||||||
|
|
||||||
## 界面预览
|
## 界面预览
|
||||||
|
|
||||||
| 总览 | 登录 |
|
<p align="center">
|
||||||
| --- | --- |
|
<img src="docs/assets/screenshot-overview.png" width="960" alt="OCI Portal 总览">
|
||||||
|  |  |
|
</p>
|
||||||
|
|
||||||
| 租户 | 任务 |
|
<details>
|
||||||
| --- | --- |
|
<summary><strong>展开更多界面截图</strong></summary>
|
||||||
|  |  |
|
|
||||||
|
|
||||||
| AI 网关 | 通知设置 |
|
| 登录 | 租户 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
|
| 任务 | AI 网关 |
|
||||||
|
| --- | --- |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
| 通知设置 |
|
||||||
|
| --- |
|
||||||
|
|  |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## 核心能力
|
## 核心能力
|
||||||
|
|
||||||
- **租户与区域**:集中管理多份 OCI API Key,支持分组、批量测活、账户画像、订阅区域缓存与区域切换;私钥和口令使用 AES-256-GCM 加密落库
|
| 能力域 | 覆盖范围 |
|
||||||
- **计算、网络与存储**:实例创建与电源操作、公网 IP、IPv6、VNIC、串行控制台连接,VCN / 子网 / 安全列表,引导卷与块存储挂载,限额和成本查询
|
| --- | --- |
|
||||||
- **自动化任务**:抢机、租户测活、成本同步、AI 渠道探测四类 cron 任务,提供执行日志、重叠执行防护、熔断与结果通知
|
| **租户与云资源** | 多 OCI API Key、分组、批量测活、账户画像、订阅区域缓存与切换;实例创建与电源操作、VNIC、公网 IP、IPv6、VCN、安全列表、引导卷、块存储挂载、限额与成本查询 |
|
||||||
- **网页控制台**:浏览器内使用 xterm 串行终端和 noVNC,通过 OCI 控制台连接建立两跳 SSH 隧道
|
| **自动化与控制台** | 抢机、租户测活、成本同步、AI 渠道探测;执行日志、重叠防护、熔断与结果通知;xterm 串行终端、noVNC 和 OCI 控制台连接两跳 SSH 隧道 |
|
||||||
- **身份与审计**:IAM 用户、MFA、API Key、密码策略、SAML 身份提供商、通知收件人和多 Identity Domain 管理;OCI Audit 事件可经 Service Connector Hub 与 Notifications 回传,关键事件按类别通过「云端事件」通知推送
|
| **身份与审计** | IAM 用户、MFA、API Key、密码策略、SAML、通知收件人与多 Identity Domain;通过 Service Connector Hub 与 Notifications 接收并分类推送 OCI Audit 事件 |
|
||||||
- **通知与安全**:Telegram、Webhook、ntfy、Bark、SMTP 五类渠道;JWT、bcrypt、TOTP、OIDC / GitHub 登录、登录锁定、IP 限速、会话撤销和系统操作审计
|
| **通知与安全** | Telegram、Webhook、ntfy、Bark、SMTP;AES-256-GCM、JWT、bcrypt、TOTP、OIDC / GitHub 登录、登录锁定、IP 限速、会话撤销和操作审计 |
|
||||||
- **AI 网关**:提供 OpenAI Responses、Chat Completions、Embeddings 与 Anthropic Messages 兼容接口,支持渠道分组、加权路由、熔断探测、模型黑白名单、密钥管理和调用日志
|
| **AI 网关** | OpenAI Responses、Chat Completions、Embeddings 与 Anthropic Messages;渠道分组、加权路由、熔断探测、模型治理、密钥管理和调用日志 |
|
||||||
|
|
||||||
## 运行形态
|
## 运行形态
|
||||||
|
|
||||||
@@ -57,7 +68,9 @@ OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和
|
|||||||
└─────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
默认推荐 SQLite 单实例部署。MySQL 和 PostgreSQL 适配仍属 experimental,不应据此推断服务支持多副本并发运行。
|
> [!NOTE]
|
||||||
|
> 默认推荐 SQLite 单实例部署。MySQL 和 PostgreSQL 适配仍属 experimental,
|
||||||
|
> 不应据此推断服务支持多副本并发运行。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@@ -65,6 +78,10 @@ OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和
|
|||||||
|
|
||||||
前置条件:Docker、Docker Compose v2、OpenSSL。
|
前置条件:Docker、Docker Compose v2、OpenSSL。
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> `.env` 中的 `DATA_KEY` 用于解密 OCI 私钥、口令和渠道凭据。首次生成后必须长期
|
||||||
|
> 保存;升级或重装时不要覆盖,否则已有密文将无法恢复。
|
||||||
|
|
||||||
1. 克隆仓库并生成一份需要长期保存的 `.env`:
|
1. 克隆仓库并生成一份需要长期保存的 `.env`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -79,19 +96,27 @@ OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和
|
|||||||
chmod 600 .env
|
chmod 600 .env
|
||||||
```
|
```
|
||||||
|
|
||||||
2. 准备数据目录并启动:
|
2. 准备数据目录:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p data
|
mkdir -p data
|
||||||
|
```
|
||||||
|
|
||||||
# Linux bind mount 需要让镜像内的 nonroot 用户(uid 65532)可写。
|
Linux 使用 bind mount 时,需要让镜像内的 nonroot 用户(uid `65532`)可写;
|
||||||
|
Docker Desktop 用户通常不需要执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
sudo chown 65532:65532 data
|
sudo chown 65532:65532 data
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 启动并检查状态:
|
||||||
|
|
||||||
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
docker compose ps
|
docker compose ps
|
||||||
```
|
```
|
||||||
|
|
||||||
3. 查看初始管理员密码并登录:
|
4. 查看初始管理员密码并登录:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
grep '^ADMIN_PASSWORD=' .env
|
grep '^ADMIN_PASSWORD=' .env
|
||||||
@@ -99,7 +124,14 @@ OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和
|
|||||||
|
|
||||||
访问 `http://127.0.0.1:18888`,默认用户名为 `admin`。
|
访问 `http://127.0.0.1:18888`,默认用户名为 `admin`。
|
||||||
|
|
||||||
> `DATA_KEY` 用于解密数据库中的 OCI 私钥、口令和渠道凭据。它只能生成一次并持续复用;丢失或更换后,已有密文无法恢复。请将 `.env` 与 `data/oci-portal.db` 一起备份。
|
启动异常时查看最近日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs --tail=100 oci-portal
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 请将 `.env` 与 `data/oci-portal.db` 成对备份;恢复时两者必须匹配。
|
||||||
|
|
||||||
### 二进制运行
|
### 二进制运行
|
||||||
|
|
||||||
@@ -107,6 +139,8 @@ Release 提供 Linux amd64 / arm64 二进制。以下以 amd64 为例;arm64
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fLO https://github.com/wangdefaa/oci-portal/releases/latest/download/oci-portal-server-linux-amd64
|
curl -fLO https://github.com/wangdefaa/oci-portal/releases/latest/download/oci-portal-server-linux-amd64
|
||||||
|
curl -fLO https://github.com/wangdefaa/oci-portal/releases/latest/download/SHA256SUMS
|
||||||
|
grep 'oci-portal-server-linux-amd64$' SHA256SUMS | sha256sum -c -
|
||||||
chmod +x oci-portal-server-linux-amd64
|
chmod +x oci-portal-server-linux-amd64
|
||||||
|
|
||||||
# 复用上文生成并妥善保存的 .env。
|
# 复用上文生成并妥善保存的 .env。
|
||||||
@@ -142,7 +176,9 @@ CGO_ENABLED=0 go build -trimpath -o bin/oci-portal-server ./cmd/server
|
|||||||
|
|
||||||
## 生产部署
|
## 生产部署
|
||||||
|
|
||||||
服务本身只提供 HTTP。除本机试用外,应保持服务仅监听回环地址或容器内部网络,并由 TLS 反向代理提供 HTTPS;否则管理员密码、JWT、AI 密钥和租户凭据会经明文连接传输。
|
> [!WARNING]
|
||||||
|
> 服务本身只提供 HTTP。除本机试用外,应仅监听回环地址或容器内部网络,并由
|
||||||
|
> TLS 反向代理提供 HTTPS;否则管理员密码、JWT、AI 密钥和租户凭据会经明文传输。
|
||||||
|
|
||||||
### Caddy
|
### Caddy
|
||||||
|
|
||||||
@@ -229,29 +265,31 @@ OpenAPI 文件随仓库维护:[`docs/swagger.yaml`](docs/swagger.yaml) · [`do
|
|||||||
|
|
||||||
### 环境变量
|
### 环境变量
|
||||||
|
|
||||||
| 变量 | 必填 | 默认值 | 说明 |
|
| 变量 | 使用条件 | 默认值 | 说明 |
|
||||||
| --- | :---: | --- | --- |
|
| --- | :---: | --- | --- |
|
||||||
| `DATA_KEY` | 是 | — | 敏感字段加密主密钥;必须持久保存,不能随意轮换 |
|
| `DATA_KEY` | 必填 | — | 敏感字段加密主密钥;必须持久保存,不能随意轮换 |
|
||||||
| `JWT_SECRET` | 是 | — | JWT 签名密钥;更换会使已有登录令牌失效 |
|
| `JWT_SECRET` | 必填 | — | JWT 签名密钥;更换会使已有登录令牌失效 |
|
||||||
| `ADMIN_USERNAME` | 否 | `admin` | 初始管理员用户名 |
|
| `ADMIN_USERNAME` | 可选 | `admin` | 初始管理员用户名 |
|
||||||
| `ADMIN_PASSWORD` | 首次启动 | — | 仅在数据库无用户时创建管理员,不会重置已有密码 |
|
| `ADMIN_PASSWORD` | 首次启动 | — | 仅在数据库无用户时创建管理员,不会重置已有密码 |
|
||||||
| `ADDR` | 否 | `:8080` | HTTP 监听地址 |
|
| `ADDR` | 可选 | `:8080` | HTTP 监听地址 |
|
||||||
| `DB_DRIVER` | 否 | `sqlite` | `sqlite` / `mysql` / `postgres`;后两者为 experimental |
|
| `DB_DRIVER` | 可选 | `sqlite` | `sqlite` / `mysql` / `postgres`;后两者为 experimental |
|
||||||
| `DB_PATH` | SQLite | `oci-portal.db` | SQLite 文件路径 |
|
| `DB_PATH` | SQLite | `oci-portal.db` | SQLite 文件路径 |
|
||||||
| `DB_DSN` | 外部数据库 | — | MySQL 需 `parseTime=True`;不要在日志或文档中暴露凭据 |
|
| `DB_DSN` | MySQL / PostgreSQL | — | MySQL 需 `parseTime=True`;不要在日志或文档中暴露凭据 |
|
||||||
| `PUBLIC_URL` | 否 | — | 面板公网基址,作为 OAuth 回调和日志回传引导的回退值 |
|
| `PUBLIC_URL` | 可选 | — | 面板公网基址,作为 OAuth 回调和日志回传引导的回退值 |
|
||||||
| `TZ` | 否 | 系统时区 | cron 表达式的解释时区;容器示例使用 `Asia/Shanghai` |
|
| `TZ` | 可选 | 系统时区 | cron 表达式的解释时区;容器示例使用 `Asia/Shanghai` |
|
||||||
| `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | 否 | — | Go 标准出站代理变量;面板内显式代理配置优先用于对应业务 |
|
| `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | 可选 | — | Go 标准出站代理变量;面板内显式代理配置优先用于对应业务 |
|
||||||
| `SWAGGER` | 否 | 关闭 | 设为 `1` 时开放 Swagger UI |
|
| `SWAGGER` | 可选 | 关闭 | 设为 `1` 时开放 Swagger UI |
|
||||||
| `GIN_MODE` | 否 | `release` | `debug` / `release` |
|
| `GIN_MODE` | 可选 | `release` | `debug` / `release` |
|
||||||
|
|
||||||
## 升级与备份
|
## 升级与备份
|
||||||
|
|
||||||
- 升级前同时备份 `.env` 和数据库;SQLite Compose 部署的数据文件为 `data/oci-portal.db`
|
1. 阅读 [CHANGELOG](CHANGELOG.md),确认目标版本的行为变化。
|
||||||
- 保持 `DATA_KEY` 不变;只恢复数据库而没有原密钥,敏感字段无法解密
|
2. 备份 `.env` 和数据库。SQLite Compose 部署建议先停止服务,再复制
|
||||||
- 服务启动时会自动执行数据库迁移;跨版本升级前先阅读 [CHANGELOG](CHANGELOG.md)
|
`data/oci-portal.db`,避免在线复制产生不一致快照。
|
||||||
- Compose 部署使用 `docker compose pull && docker compose up -d` 更新镜像
|
3. 保持原 `DATA_KEY` 不变;只有数据库而没有对应密钥时,敏感字段无法解密。
|
||||||
- OCI API Key 应遵循最小权限原则;生产环境保持 Swagger 关闭并限制管理面访问来源
|
4. Compose 部署执行 `docker compose pull`,再执行 `docker compose up -d`;服务启动时
|
||||||
|
会自动完成数据库迁移。
|
||||||
|
5. 生产环境保持 Swagger 关闭、限制管理面访问来源,并为 OCI API Key 配置最小权限。
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
|
|||||||
+116
-56
@@ -1,23 +1,33 @@
|
|||||||
|
<a id="top"></a>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="assets/logo.svg" width="88" alt="OCI Portal logo">
|
||||||
|
|
||||||
# AI 网关
|
# AI 网关
|
||||||
|
|
||||||
|
**将多路 OCI Generative AI 统一为 OpenAI、Anthropic 与 xAI 兼容接口**
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
[快速接入](#quick-start) · [端点一览](#endpoints) · [路由机制](#routing) · [Codex 接入](#codex) · [已知限制](#limitations) · [兼容矩阵](#compatibility)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> OCI Portal AI 网关将多个 OCI GenAI 渠道统一为 OpenAI、Anthropic 与 xAI
|
> 网关集中处理密钥鉴权、模型访问控制、渠道调度与协议适配。路径、参数和
|
||||||
> 兼容接口,并集中处理鉴权、模型访问控制、渠道调度和协议适配。
|
> 响应结构以 [Swagger YAML](swagger.yaml) 或运行时 Swagger UI 为准;协议差异、
|
||||||
>
|
> 兼容改写和实测边界以本文为准。
|
||||||
> 路径、参数与响应结构以 [Swagger YAML](swagger.yaml) 或运行时 Swagger UI
|
|
||||||
> 为准;协议差异、兼容改写和实测边界以本文为准。
|
|
||||||
|
|
||||||
**兼容快照:2026-07-15**
|
| 文档属性 | 当前值 |
|
||||||
|
| --- | --- |
|
||||||
## 快速导航
|
| 兼容快照 | **2026-07-16** |
|
||||||
|
| API 基址 | `/ai/v1` |
|
||||||
- [快速接入](#quick-start)
|
| 首选对话协议 | OpenAI Responses |
|
||||||
- [端点一览](#endpoints)
|
| 会话模式 | 无状态,客户端携带完整上下文 |
|
||||||
- [路由与全局行为](#routing)
|
|
||||||
- [Codex 接入](#codex)
|
|
||||||
- [已知限制](#limitations)
|
|
||||||
- [字段兼容矩阵](#compatibility)
|
|
||||||
- [实现索引](#implementation)
|
|
||||||
|
|
||||||
<a id="quick-start"></a>
|
<a id="quick-start"></a>
|
||||||
|
|
||||||
@@ -25,19 +35,13 @@
|
|||||||
|
|
||||||
### 基础地址与鉴权
|
### 基础地址与鉴权
|
||||||
|
|
||||||
```text
|
网关密钥在管理面板中创建。连接信息如下:
|
||||||
Base URL: https://<网关地址>/ai/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
网关密钥在管理面板中创建,支持以下任一请求头:
|
| 项目 | 配置 |
|
||||||
|
| --- | --- |
|
||||||
```http
|
| Base URL | `https://<网关地址>/ai/v1` |
|
||||||
Authorization: Bearer sk-...
|
| Bearer 鉴权 | `Authorization: Bearer sk-...` |
|
||||||
```
|
| API Key 鉴权 | `x-api-key: sk-...` |
|
||||||
|
|
||||||
```http
|
|
||||||
x-api-key: sk-...
|
|
||||||
```
|
|
||||||
|
|
||||||
密钥可绑定渠道分组和模型白名单。全局模型黑名单会同时作用于模型列表、
|
密钥可绑定渠道分组和模型白名单。全局模型黑名单会同时作用于模型列表、
|
||||||
请求路由和探测候选;开启「过滤弃用模型」后,OCI 已宣布弃用的模型也会从
|
请求路由和探测候选;开启「过滤弃用模型」后,OCI 已宣布弃用的模型也会从
|
||||||
@@ -50,6 +54,15 @@ curl "https://<网关地址>/ai/v1/models" \
|
|||||||
-H "Authorization: Bearer $OCI_PORTAL_KEY"
|
-H "Authorization: Bearer $OCI_PORTAL_KEY"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
再发起一条最小 Responses 请求;请将示例模型替换为模型列表中的可见模型:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://<网关地址>/ai/v1/responses" \
|
||||||
|
-H "Authorization: Bearer $OCI_PORTAL_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"model":"xai.grok-4.3","input":"你好,请用一句话介绍自己。"}'
|
||||||
|
```
|
||||||
|
|
||||||
<a id="endpoints"></a>
|
<a id="endpoints"></a>
|
||||||
|
|
||||||
## 端点一览
|
## 端点一览
|
||||||
@@ -66,12 +79,14 @@ curl "https://<网关地址>/ai/v1/models" \
|
|||||||
| 安全 | `POST /ai/v1/moderations` | OpenAI 外壳映射 OCI Guardrails | — |
|
| 安全 | `POST /ai/v1/moderations` | OpenAI 外壳映射 OCI Guardrails | — |
|
||||||
| 发现 | `GET /ai/v1/models` | 当前密钥可见模型列表 | — |
|
| 发现 | `GET /ai/v1/models` | 当前密钥可见模型列表 | — |
|
||||||
|
|
||||||
选择建议:
|
### 如何选择协议
|
||||||
|
|
||||||
- 新客户端优先使用 **Responses**
|
| 使用场景 | 推荐接口 |
|
||||||
- Anthropic SDK 或 Claude 生态客户端使用 **Messages**
|
| --- | --- |
|
||||||
- 仅支持旧 OpenAI 对话协议的客户端使用 **Chat Completions**
|
| 新客户端、推理模型、服务端工具 | **Responses** |
|
||||||
- Embeddings、TTS、Rerank 与 Moderations 使用各自专用端点
|
| Anthropic SDK、Claude 生态客户端 | **Messages** |
|
||||||
|
| 仅支持旧 OpenAI 对话协议的客户端 | **Chat Completions** |
|
||||||
|
| 向量、语音、重排与安全审核 | 对应专用端点 |
|
||||||
|
|
||||||
<a id="routing"></a>
|
<a id="routing"></a>
|
||||||
|
|
||||||
@@ -106,6 +121,14 @@ flowchart LR
|
|||||||
| 文件输入 | `input_file` 会被 OCI ZDR 形态拒绝,详见[已知限制](#limitations) |
|
| 文件输入 | `input_file` 会被 OCI ZDR 形态拒绝,详见[已知限制](#limitations) |
|
||||||
| Chat 定位 | Chat Completions 只承担协议转换与兼容修复;新能力优先落在 Responses 与 Messages |
|
| Chat 定位 | Chat Completions 只承担协议转换与兼容修复;新能力优先落在 Responses 与 Messages |
|
||||||
|
|
||||||
|
### 可配置运行策略
|
||||||
|
|
||||||
|
| 策略 | 默认行为 | 配置入口 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Responses 流式保险丝 | 开启;阈值 `60 KB`,按 `instructions` 与 `tools` 两个字段的原始 JSON 值计算 | **设置 → AI → 流式保险丝** |
|
||||||
|
| Grok 服务端搜索 | 对 `xai.` 模型默认注入 `web_search` 与 `x_search`;同名工具不重复覆盖 | **设置 → AI → Grok 服务端搜索工具** |
|
||||||
|
| 弃用模型过滤 | 开启后从模型列表、请求路由与探测候选中统一排除 | **设置 → AI → 模型治理** |
|
||||||
|
|
||||||
<a id="codex"></a>
|
<a id="codex"></a>
|
||||||
|
|
||||||
## Codex 接入
|
## Codex 接入
|
||||||
@@ -143,7 +166,7 @@ model = "xai.grok-4.3"
|
|||||||
```
|
```
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> `codex-cli 0.144.1` 已实测主会话和 multi-agent 子代理全链路可用。
|
> `Codex CLI 0.144.1` 已实测主会话和 multi-agent 子代理全链路可用。
|
||||||
> 内置 worker 可能先尝试 `gpt-5.6-luna` 或 `gpt-5.4`;网关没有对应渠道时
|
> 内置 worker 可能先尝试 `gpt-5.6-luna` 或 `gpt-5.4`;网关没有对应渠道时
|
||||||
> 会出现少量 404,随后由 Codex 回落到可用模型。自定义 agent 的模型覆盖是否
|
> 会出现少量 404,随后由 Codex 回落到可用模型。自定义 agent 的模型覆盖是否
|
||||||
> 直接生效取决于 Codex 版本;0.144.1 的实测主要依赖自动回落。
|
> 直接生效取决于 Codex 版本;0.144.1 的实测主要依赖自动回落。
|
||||||
@@ -165,22 +188,35 @@ Codex 工具兼容现状:
|
|||||||
|
|
||||||
## 已知限制
|
## 已知限制
|
||||||
|
|
||||||
### 超大流式请求
|
### `instructions` / `tools` 大体量流式断流
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> 2026-07 实测:OCI xAI 兼容面对**完整请求体超过约 82 KB** 的流式请求,
|
> `instructions` 与 `tools` 两个字段的原始 JSON 值合计超过约 **64.5 KB** 时,
|
||||||
> 可能在推理阶段提前断流;该现象由请求体积触发,与工具构成或字符集无关,
|
> 上游流式请求可能在推理阶段静默断连:连接直接 EOF,不发送 `error` 或终态事件。
|
||||||
> 同一请求改为非流式可正常完成。
|
> 同一请求改为非流式实测可正常完成;单独扩大 `input` 未触发该限制。
|
||||||
|
|
||||||
网关按协议采取不同保护:
|
该问题于 2026-07-13 通过字节级二分定位,2026-07-16 在 Chicago 复测仍存在:
|
||||||
|
`70.4 KB` 断流、`59.7 KB` 正常,API Key 与签名鉴权表现一致。本文及设置页中的
|
||||||
|
`KB` 均按 `1024 B` 计算。
|
||||||
|
|
||||||
- **Responses**:兼容改写后的请求体超过 **76 KB** 时,预防性改为非流式上游
|
| 协议 | 网关保护 | 客户端表现 |
|
||||||
请求,再合成最小 SSE 序列(`response.created` →
|
| --- | --- | --- |
|
||||||
`response.output_item.done` → `response.completed`);结果语义保留,但没有增量输出
|
| Responses | 保险丝默认开启;超过 `60 KB` 时改走非流式上游,并合成最小 SSE 序列 | 结果语义保留,但不再增量输出 |
|
||||||
- **Chat Completions / Messages**:若上游在客户端收到任何内容前断流,自动用
|
| Chat Completions / Messages | 客户端尚未收到内容就断流时,自动改用非流式重做 | 合成对应 chunk / event 序列 |
|
||||||
非流式重做,并合成对应 chunk / event 序列
|
| 已开始输出的流 | 无法透明重试;调用日志记录提前终止 | 客户端可能只收到部分事件 |
|
||||||
- **已开始输出的流**:不能透明重试;调用日志会记录提前终止,客户端可能只拿到
|
|
||||||
部分事件
|
Responses 合成的最小事件序列为:`response.created` →
|
||||||
|
`response.output_item.done` → `response.completed`。保险丝可在
|
||||||
|
**设置 → AI → 流式保险丝** 调整或关闭。
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>历史问题:完整请求体体积断流(已由上游修复)</strong></summary>
|
||||||
|
|
||||||
|
2026-07-15 曾在完整请求体约 `82 KB`(含 `input`)时观测到纯体积流式断流。
|
||||||
|
2026-07-16 复核 `83 KB`、真实 Codex 形态 `104.5 KB`、`200 KB` 与 `400 KB`
|
||||||
|
请求均正常完成;Chicago 与 Phoenix、签名与 API Key 两条路径结果一致。
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
### ZDR 与文件输入
|
### ZDR 与文件输入
|
||||||
|
|
||||||
@@ -192,16 +228,20 @@ File content is currently unsupported for ZDR customers
|
|||||||
|
|
||||||
网关强制 `store:false`,属于 ZDR 请求形态,因此当前不能通过该端点上传或引用文件。
|
网关强制 `store:false`,属于 ZDR 请求形态,因此当前不能通过该端点上传或引用文件。
|
||||||
|
|
||||||
### 规格与实测边界
|
|
||||||
|
|
||||||
本项目提供的是兼容接口,而不是 OpenAI、Anthropic 或 xAI 协议的完整实现。
|
|
||||||
部分 OCI OpenAI 兼容行为来自实测,未见 Oracle 文档合同,可能随上游调整。
|
|
||||||
|
|
||||||
<a id="compatibility"></a>
|
<a id="compatibility"></a>
|
||||||
|
|
||||||
## 字段兼容矩阵
|
## 字段兼容矩阵
|
||||||
|
|
||||||
矩阵基线:
|
> [!IMPORTANT]
|
||||||
|
> 本项目提供兼容接口,而不是 OpenAI、Anthropic 或 xAI 协议的完整实现。部分 OCI
|
||||||
|
> OpenAI 兼容行为来自实测,未见 Oracle 文档合同,可能随上游调整。
|
||||||
|
|
||||||
|
### 矩阵索引
|
||||||
|
|
||||||
|
[Responses](#compat-responses) · [Chat Completions](#compat-chat) · [Messages](#compat-messages) · [Embeddings](#compat-embeddings) · [语音生成](#compat-audio) · [Rerank](#compat-rerank) · [Moderations](#compat-moderations) · [Models](#compat-models)
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>展开参考规格</strong></summary>
|
||||||
|
|
||||||
- [OpenAI Responses](https://developers.openai.com/api/reference/resources/responses/methods/create)
|
- [OpenAI Responses](https://developers.openai.com/api/reference/resources/responses/methods/create)
|
||||||
- [OpenAI Chat Completions](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create)
|
- [OpenAI Chat Completions](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create)
|
||||||
@@ -211,6 +251,8 @@ File content is currently unsupported for ZDR customers
|
|||||||
- [Anthropic Messages](https://platform.claude.com/docs/en/api/messages/create)
|
- [Anthropic Messages](https://platform.claude.com/docs/en/api/messages/create)
|
||||||
- [Cohere Rerank](https://docs.cohere.com/reference/rerank)
|
- [Cohere Rerank](https://docs.cohere.com/reference/rerank)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
| 标记 | 含义 |
|
| 标记 | 含义 |
|
||||||
| :---: | --- |
|
| :---: | --- |
|
||||||
| ✅ | 网关直接支持 |
|
| ✅ | 网关直接支持 |
|
||||||
@@ -218,6 +260,8 @@ File content is currently unsupported for ZDR customers
|
|||||||
| 🔄 | 网关执行字段或协议转换后支持 |
|
| 🔄 | 网关执行字段或协议转换后支持 |
|
||||||
| ◐ | 部分支持、存在前置条件或语义降级 |
|
| ◐ | 部分支持、存在前置条件或语义降级 |
|
||||||
|
|
||||||
|
<a id="compat-responses"></a>
|
||||||
|
|
||||||
### OpenAI Responses
|
### OpenAI Responses
|
||||||
|
|
||||||
**`POST /ai/v1/responses` · 无状态主接口**
|
**`POST /ai/v1/responses` · 无状态主接口**
|
||||||
@@ -239,12 +283,12 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
| `reasoning` | ➡️ | 整个对象保留;`effort` 不校验档位 |
|
| `reasoning` | ➡️ | 整个对象保留;`effort` 不校验档位 |
|
||||||
| `tool_choice` | ◐ | 普通形态保留;namespace 对象会重限定,全部工具被剥离时删除 |
|
| `tool_choice` | ◐ | 普通形态保留;namespace 对象会重限定,全部工具被剥离时删除 |
|
||||||
| `store` | 🔄 | 无论客户端传什么,上游请求都强制改写为 `false` |
|
| `store` | 🔄 | 无论客户端传什么,上游请求都强制改写为 `false` |
|
||||||
| `stream` | ◐ | 支持 SSE;请求体超过 76 KB 时改为非流式上游并合成 SSE |
|
| `stream` | ◐ | 支持 SSE;`instructions` 与 `tools` 的原始 JSON 值合计超过保险丝阈值时,预防性改走非流式上游(默认开启、`60 KB`,见[已知限制](#limitations)) |
|
||||||
| 其余标准与未知顶层字段 | ➡️ | `context_management`、`include`、`metadata`、`prompt`、`prompt_cache_key`、`service_tier`、`truncation`、`user` 等均保留,由 OCI 决定是否接受 |
|
| 其余标准与未知顶层字段 | ➡️ | `context_management`、`include`、`metadata`、`prompt`、`prompt_cache_key`、`service_tier`、`truncation`、`user` 等均保留,由 OCI 决定是否接受 |
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details open>
|
<details>
|
||||||
<summary><strong>工具兼容</strong></summary>
|
<summary><strong>工具兼容</strong></summary>
|
||||||
|
|
||||||
| 工具或参数 | 状态 | 网关行为 |
|
| 工具或参数 | 状态 | 网关行为 |
|
||||||
@@ -254,7 +298,7 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
| `tools[].type=mcp` | ➡️ | 远程 MCP 由上游直连,`server_url`、`require_approval`、`authorization` 等保留 |
|
| `tools[].type=mcp` | ➡️ | 远程 MCP 由上游直连,`server_url`、`require_approval`、`authorization` 等保留 |
|
||||||
| `tools[].type=namespace` | 🔄 | 子 `function` 上提并限定命名;响应、历史调用和 `tool_choice` 会反向还原;组内非 `function` 子工具被剥离 |
|
| `tools[].type=namespace` | 🔄 | 子 `function` 上提并限定命名;响应、历史调用和 `tool_choice` 会反向还原;组内非 `function` 子工具被剥离 |
|
||||||
| `tools[].type=custom` | ◐ | 顶层非 `apply_patch` 工具转为带 `input` schema 的 `function`;响应与多轮历史双向回转;`format` 会删除 |
|
| `tools[].type=custom` | ◐ | 顶层非 `apply_patch` 工具转为带 `input` schema 的 `function`;响应与多轮历史双向回转;`format` 会删除 |
|
||||||
| `custom:apply_patch` | ◐ | 整体剥离;grok 系未针对 Codex 补丁格式训练,模型应回落其他编辑方式 |
|
| `custom:apply_patch` | ◐ | 整体剥离;Grok 系未针对 Codex 补丁格式训练,模型应回落其他编辑方式 |
|
||||||
| `tools[].type=tool_search` | ◐ | 请求可被接受,但工具本身直接剥离 |
|
| `tools[].type=tool_search` | ◐ | 请求可被接受,但工具本身直接剥离 |
|
||||||
| `web_search.external_web_access=true` | 🔄 | 删除上游不识别的字段,保留 `web_search` |
|
| `web_search.external_web_access=true` | 🔄 | 删除上游不识别的字段,保留 `web_search` |
|
||||||
| `web_search.external_web_access=false` | ◐ | 上游没有“仅缓存检索”对应能力,按不越权原则剥离整个工具 |
|
| `web_search.external_web_access=false` | ◐ | 上游没有“仅缓存检索”对应能力,按不越权原则剥离整个工具 |
|
||||||
@@ -277,6 +321,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
- 未知模型返回 404,无可用渠道返回 503
|
- 未知模型返回 404,无可用渠道返回 503
|
||||||
- 上游错误使用 OpenAI 风格错误外壳,但不保证字段与标准 OpenAI 完全一致
|
- 上游错误使用 OpenAI 风格错误外壳,但不保证字段与标准 OpenAI 完全一致
|
||||||
|
|
||||||
|
<a id="compat-chat"></a>
|
||||||
|
|
||||||
### OpenAI Chat Completions
|
### OpenAI Chat Completions
|
||||||
|
|
||||||
**`POST /ai/v1/chat/completions` · 存量客户端兼容层**
|
**`POST /ai/v1/chat/completions` · 存量客户端兼容层**
|
||||||
@@ -332,6 +378,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
`prompt_tokens_details.cached_tokens`
|
`prompt_tokens_details.cached_tokens`
|
||||||
- 已开始输出的流中断不会转换成标准 SSE 错误事件
|
- 已开始输出的流中断不会转换成标准 SSE 错误事件
|
||||||
|
|
||||||
|
<a id="compat-messages"></a>
|
||||||
|
|
||||||
### Anthropic Messages
|
### Anthropic Messages
|
||||||
|
|
||||||
**`POST /ai/v1/messages` · Anthropic 协议转换层**
|
**`POST /ai/v1/messages` · Anthropic 协议转换层**
|
||||||
@@ -385,6 +433,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
- usage 只保留 `input_tokens`、`output_tokens` 与 `cache_read_input_tokens`
|
- usage 只保留 `input_tokens`、`output_tokens` 与 `cache_read_input_tokens`
|
||||||
- 流式上游错误与无终态断流会转成 Anthropic `error` 事件
|
- 流式上游错误与无终态断流会转成 Anthropic `error` 事件
|
||||||
|
|
||||||
|
<a id="compat-embeddings"></a>
|
||||||
|
|
||||||
### OpenAI Embeddings
|
### OpenAI Embeddings
|
||||||
|
|
||||||
**`POST /ai/v1/embeddings` · 向量化专用端点**
|
**`POST /ai/v1/embeddings` · 向量化专用端点**
|
||||||
@@ -402,6 +452,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
|
|
||||||
响应使用标准 `object:"list"` 外壳,向量为 `float32` 数组,不支持流式。
|
响应使用标准 `object:"list"` 外壳,向量为 `float32` 数组,不支持流式。
|
||||||
|
|
||||||
|
<a id="compat-audio"></a>
|
||||||
|
|
||||||
### 语音生成
|
### 语音生成
|
||||||
|
|
||||||
两个端点最终使用同一 OCI xAI TTS 上游与渠道调度:
|
两个端点最终使用同一 OCI xAI TTS 上游与渠道调度:
|
||||||
@@ -442,6 +494,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
- 不提供 HTTP 流式音频或 WebSocket 代理
|
- 不提供 HTTP 流式音频或 WebSocket 代理
|
||||||
- 无 token 用量口径,调用日志只记时延与渠道
|
- 无 token 用量口径,调用日志只记时延与渠道
|
||||||
|
|
||||||
|
<a id="compat-rerank"></a>
|
||||||
|
|
||||||
### Rerank
|
### Rerank
|
||||||
|
|
||||||
**`POST /ai/v1/rerank` · Cohere / Jina 风格协议**
|
**`POST /ai/v1/rerank` · Cohere / Jina 风格协议**
|
||||||
@@ -459,6 +513,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
`max_tokens_per_doc` 与未知字段静默忽略。响应按相关度降序,
|
`max_tokens_per_doc` 与未知字段静默忽略。响应按相关度降序,
|
||||||
`results[].index` 指向输入下标,`relevance_score` 为 0~1 浮点;无 token 用量口径。
|
`results[].index` 指向输入下标,`relevance_score` 为 0~1 浮点;无 token 用量口径。
|
||||||
|
|
||||||
|
<a id="compat-moderations"></a>
|
||||||
|
|
||||||
### Moderations
|
### Moderations
|
||||||
|
|
||||||
**`POST /ai/v1/moderations` · OpenAI 外壳映射 OCI Guardrails**
|
**`POST /ai/v1/moderations` · OpenAI 外壳映射 OCI Guardrails**
|
||||||
@@ -480,6 +536,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
- 实测中文人名与手机号识别较弱,英文 PII 识别正常
|
- 实测中文人名与手机号识别较弱,英文 PII 识别正常
|
||||||
- 响应 `model` 恒为 `oci-guardrails`
|
- 响应 `model` 恒为 `oci-guardrails`
|
||||||
|
|
||||||
|
<a id="compat-models"></a>
|
||||||
|
|
||||||
### Models
|
### Models
|
||||||
|
|
||||||
**`GET /ai/v1/models` · 当前密钥可见模型列表**
|
**`GET /ai/v1/models` · 当前密钥可见模型列表**
|
||||||
@@ -496,7 +554,7 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
|
|
||||||
<a id="implementation"></a>
|
<a id="implementation"></a>
|
||||||
|
|
||||||
## 实现索引
|
## 附录:实现索引
|
||||||
|
|
||||||
| 端点 / 能力 | 主要实现 |
|
| 端点 / 能力 | 主要实现 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -510,3 +568,5 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
|
|
||||||
本文是兼容性快照,不替代 Swagger。标准接口、Codex 客户端与 OCI 上游均可能
|
本文是兼容性快照,不替代 Swagger。标准接口、Codex 客户端与 OCI 上游均可能
|
||||||
变化,最终行为以当前版本代码、运行时 Swagger 和实测结果为准。
|
变化,最终行为以当前版本代码、运行时 Swagger 和实测结果为准。
|
||||||
|
|
||||||
|
[返回顶部](#top)
|
||||||
|
|||||||
+68
-1
@@ -786,6 +786,27 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/ai-model-catalog": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"AI 管理"
|
||||||
|
],
|
||||||
|
"summary": "聚合模型目录",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/internal_api.itemsResponse-oci-portal_internal_service_AggregatedModel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/ai-models": {
|
"/api/v1/ai-models": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -839,7 +860,7 @@ const docTemplate = `{
|
|||||||
"summary": "更新 AI 网关全局设置",
|
"summary": "更新 AI 网关全局设置",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"description": "开启后已宣布弃用(即使未退役)的模型从列表与路由中排除",
|
"description": "全量提交;保险丝阈值限 1..1024 KB",
|
||||||
"name": "body",
|
"name": "body",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -854,6 +875,15 @@ const docTemplate = `{
|
|||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/internal_api.aiSettingsResponse"
|
"$ref": "#/definitions/internal_api.aiSettingsResponse"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6050,7 +6080,22 @@ const docTemplate = `{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"filterDeprecated": {
|
"filterDeprecated": {
|
||||||
|
"description": "FilterDeprecated 开启后已宣布弃用(即使未退役)的模型从列表与路由中排除",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"grokWebSearch": {
|
||||||
|
"description": "GrokWebSearch / GrokXSearch 是 xai. 模型服务端搜索工具默认注入开关;\n请求 tools 已包含同名工具时不覆盖",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"grokXSearch": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"streamGuardEnabled": {
|
||||||
|
"description": "StreamGuardEnabled / StreamGuardKB 是 Responses 流式保险丝:\ninstructions+tools 合计超阈值(KB)的流式请求改非流式上游并合成 SSE",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"streamGuardKB": {
|
||||||
|
"type": "integer"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -6621,6 +6666,17 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"internal_api.itemsResponse-oci-portal_internal_service_AggregatedModel": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/oci-portal_internal_service.AggregatedModel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"internal_api.itemsResponse-oci-portal_internal_service_NotifyChannelView": {
|
"internal_api.itemsResponse-oci-portal_internal_service_NotifyChannelView": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -9358,6 +9414,17 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"oci-portal_internal_service.AggregatedModel": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"capability": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"oci-portal_internal_service.AuditEventsView": {
|
"oci-portal_internal_service.AuditEventsView": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
+68
-1
@@ -779,6 +779,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/ai-model-catalog": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"AI 管理"
|
||||||
|
],
|
||||||
|
"summary": "聚合模型目录",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/internal_api.itemsResponse-oci-portal_internal_service_AggregatedModel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/ai-models": {
|
"/api/v1/ai-models": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -832,7 +853,7 @@
|
|||||||
"summary": "更新 AI 网关全局设置",
|
"summary": "更新 AI 网关全局设置",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"description": "开启后已宣布弃用(即使未退役)的模型从列表与路由中排除",
|
"description": "全量提交;保险丝阈值限 1..1024 KB",
|
||||||
"name": "body",
|
"name": "body",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -847,6 +868,15 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/internal_api.aiSettingsResponse"
|
"$ref": "#/definitions/internal_api.aiSettingsResponse"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6043,7 +6073,22 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"filterDeprecated": {
|
"filterDeprecated": {
|
||||||
|
"description": "FilterDeprecated 开启后已宣布弃用(即使未退役)的模型从列表与路由中排除",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"grokWebSearch": {
|
||||||
|
"description": "GrokWebSearch / GrokXSearch 是 xai. 模型服务端搜索工具默认注入开关;\n请求 tools 已包含同名工具时不覆盖",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"grokXSearch": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"streamGuardEnabled": {
|
||||||
|
"description": "StreamGuardEnabled / StreamGuardKB 是 Responses 流式保险丝:\ninstructions+tools 合计超阈值(KB)的流式请求改非流式上游并合成 SSE",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"streamGuardKB": {
|
||||||
|
"type": "integer"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -6614,6 +6659,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"internal_api.itemsResponse-oci-portal_internal_service_AggregatedModel": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/oci-portal_internal_service.AggregatedModel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"internal_api.itemsResponse-oci-portal_internal_service_NotifyChannelView": {
|
"internal_api.itemsResponse-oci-portal_internal_service_NotifyChannelView": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -9351,6 +9407,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"oci-portal_internal_service.AggregatedModel": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"capability": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"oci-portal_internal_service.AuditEventsView": {
|
"oci-portal_internal_service.AuditEventsView": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
+48
-1
@@ -49,7 +49,22 @@ definitions:
|
|||||||
internal_api.aiSettingsResponse:
|
internal_api.aiSettingsResponse:
|
||||||
properties:
|
properties:
|
||||||
filterDeprecated:
|
filterDeprecated:
|
||||||
|
description: FilterDeprecated 开启后已宣布弃用(即使未退役)的模型从列表与路由中排除
|
||||||
type: boolean
|
type: boolean
|
||||||
|
grokWebSearch:
|
||||||
|
description: |-
|
||||||
|
GrokWebSearch / GrokXSearch 是 xai. 模型服务端搜索工具默认注入开关;
|
||||||
|
请求 tools 已包含同名工具时不覆盖
|
||||||
|
type: boolean
|
||||||
|
grokXSearch:
|
||||||
|
type: boolean
|
||||||
|
streamGuardEnabled:
|
||||||
|
description: |-
|
||||||
|
StreamGuardEnabled / StreamGuardKB 是 Responses 流式保险丝:
|
||||||
|
instructions+tools 合计超阈值(KB)的流式请求改非流式上游并合成 SSE
|
||||||
|
type: boolean
|
||||||
|
streamGuardKB:
|
||||||
|
type: integer
|
||||||
type: object
|
type: object
|
||||||
internal_api.attachBootVolumeRequest:
|
internal_api.attachBootVolumeRequest:
|
||||||
properties:
|
properties:
|
||||||
@@ -427,6 +442,13 @@ definitions:
|
|||||||
$ref: '#/definitions/oci-portal_internal_model.UserIdentity'
|
$ref: '#/definitions/oci-portal_internal_model.UserIdentity'
|
||||||
type: array
|
type: array
|
||||||
type: object
|
type: object
|
||||||
|
internal_api.itemsResponse-oci-portal_internal_service_AggregatedModel:
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/oci-portal_internal_service.AggregatedModel'
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
internal_api.itemsResponse-oci-portal_internal_service_NotifyChannelView:
|
internal_api.itemsResponse-oci-portal_internal_service_NotifyChannelView:
|
||||||
properties:
|
properties:
|
||||||
items:
|
items:
|
||||||
@@ -2232,6 +2254,13 @@ definitions:
|
|||||||
description: 卷本身的名称(attachment 名是自动生成的)
|
description: 卷本身的名称(attachment 名是自动生成的)
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
oci-portal_internal_service.AggregatedModel:
|
||||||
|
properties:
|
||||||
|
capability:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
oci-portal_internal_service.AuditEventsView:
|
oci-portal_internal_service.AuditEventsView:
|
||||||
properties:
|
properties:
|
||||||
cursor:
|
cursor:
|
||||||
@@ -3135,6 +3164,18 @@ paths:
|
|||||||
summary: AI 调用日志列表
|
summary: AI 调用日志列表
|
||||||
tags:
|
tags:
|
||||||
- AI 管理
|
- AI 管理
|
||||||
|
/api/v1/ai-model-catalog:
|
||||||
|
get:
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/internal_api.itemsResponse-oci-portal_internal_service_AggregatedModel'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 聚合模型目录
|
||||||
|
tags:
|
||||||
|
- AI 管理
|
||||||
/api/v1/ai-models:
|
/api/v1/ai-models:
|
||||||
get:
|
get:
|
||||||
responses:
|
responses:
|
||||||
@@ -3161,7 +3202,7 @@ paths:
|
|||||||
- AI 管理
|
- AI 管理
|
||||||
put:
|
put:
|
||||||
parameters:
|
parameters:
|
||||||
- description: 开启后已宣布弃用(即使未退役)的模型从列表与路由中排除
|
- description: 全量提交;保险丝阈值限 1..1024 KB
|
||||||
in: body
|
in: body
|
||||||
name: body
|
name: body
|
||||||
required: true
|
required: true
|
||||||
@@ -3172,6 +3213,12 @@ paths:
|
|||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/internal_api.aiSettingsResponse'
|
$ref: '#/definitions/internal_api.aiSettingsResponse'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
security:
|
security:
|
||||||
- BearerAuth: []
|
- BearerAuth: []
|
||||||
summary: 更新 AI 网关全局设置
|
summary: 更新 AI 网关全局设置
|
||||||
|
|||||||
+57
-5
@@ -422,9 +422,47 @@ func aiPathID(c *gin.Context) (uint, bool) {
|
|||||||
return uint(id), true
|
return uint(id), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// modelCatalog 返回启用渠道聚合去重后的模型目录(含能力),黑名单添加弹窗用。
|
||||||
|
//
|
||||||
|
// @Summary 聚合模型目录
|
||||||
|
// @Tags AI 管理
|
||||||
|
// @Success 200 {object} itemsResponse[service.AggregatedModel]
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/ai-model-catalog [get]
|
||||||
|
func (h *aiAdminHandler) modelCatalog(c *gin.Context) {
|
||||||
|
items, err := h.gw.AggregatedModels(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
// aiSettingsResponse 是 AI 网关全局设置(文档与响应共用)。
|
// aiSettingsResponse 是 AI 网关全局设置(文档与响应共用)。
|
||||||
type aiSettingsResponse struct {
|
type aiSettingsResponse struct {
|
||||||
|
// FilterDeprecated 开启后已宣布弃用(即使未退役)的模型从列表与路由中排除
|
||||||
FilterDeprecated bool `json:"filterDeprecated"`
|
FilterDeprecated bool `json:"filterDeprecated"`
|
||||||
|
// StreamGuardEnabled / StreamGuardKB 是 Responses 流式保险丝:
|
||||||
|
// instructions+tools 合计超阈值(KB)的流式请求改非流式上游并合成 SSE
|
||||||
|
StreamGuardEnabled bool `json:"streamGuardEnabled"`
|
||||||
|
StreamGuardKB int `json:"streamGuardKB"`
|
||||||
|
// GrokWebSearch / GrokXSearch 是 xai. 模型服务端搜索工具默认注入开关;
|
||||||
|
// 请求 tools 已包含同名工具时不覆盖
|
||||||
|
GrokWebSearch bool `json:"grokWebSearch"`
|
||||||
|
GrokXSearch bool `json:"grokXSearch"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentAiSettings 汇总网关运行时设置为响应体。
|
||||||
|
func (h *aiAdminHandler) currentAiSettings() aiSettingsResponse {
|
||||||
|
guardOn, guardKB := h.gw.StreamGuard()
|
||||||
|
web, x := h.gw.GrokSearch()
|
||||||
|
return aiSettingsResponse{
|
||||||
|
FilterDeprecated: h.gw.FilterDeprecated(),
|
||||||
|
StreamGuardEnabled: guardOn,
|
||||||
|
StreamGuardKB: guardKB,
|
||||||
|
GrokWebSearch: web,
|
||||||
|
GrokXSearch: x,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// aiSettings 返回 AI 网关全局设置。
|
// aiSettings 返回 AI 网关全局设置。
|
||||||
@@ -435,15 +473,16 @@ type aiSettingsResponse struct {
|
|||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-settings [get]
|
// @Router /api/v1/ai-settings [get]
|
||||||
func (h *aiAdminHandler) aiSettings(c *gin.Context) {
|
func (h *aiAdminHandler) aiSettings(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, aiSettingsResponse{FilterDeprecated: h.gw.FilterDeprecated()})
|
c.JSON(http.StatusOK, h.currentAiSettings())
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateAiSettings 更新 AI 网关全局设置(当前仅「过滤弃用模型」开关)。
|
// updateAiSettings 更新 AI 网关全局设置(过滤弃用/流式保险丝/grok 搜索工具默认注入)。
|
||||||
//
|
//
|
||||||
// @Summary 更新 AI 网关全局设置
|
// @Summary 更新 AI 网关全局设置
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Param body body aiSettingsResponse true "开启后已宣布弃用(即使未退役)的模型从列表与路由中排除"
|
// @Param body body aiSettingsResponse true "全量提交;保险丝阈值限 1..1024 KB"
|
||||||
// @Success 200 {object} aiSettingsResponse
|
// @Success 200 {object} aiSettingsResponse
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-settings [put]
|
// @Router /api/v1/ai-settings [put]
|
||||||
func (h *aiAdminHandler) updateAiSettings(c *gin.Context) {
|
func (h *aiAdminHandler) updateAiSettings(c *gin.Context) {
|
||||||
@@ -452,9 +491,22 @@ func (h *aiAdminHandler) updateAiSettings(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.gw.SetFilterDeprecated(c.Request.Context(), req.FilterDeprecated); err != nil {
|
if req.StreamGuardKB < 1 || req.StreamGuardKB > 1024 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "streamGuardKB 须在 1..1024"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
if err := h.gw.SetFilterDeprecated(ctx, req.FilterDeprecated); err != nil {
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, aiSettingsResponse{FilterDeprecated: h.gw.FilterDeprecated()})
|
if err := h.gw.SetStreamGuard(ctx, req.StreamGuardEnabled, req.StreamGuardKB); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.gw.SetGrokSearch(ctx, req.GrokWebSearch, req.GrokXSearch); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, h.currentAiSettings())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -587,8 +587,13 @@ func (h *aiGatewayHandler) responsesPassthrough(c *gin.Context, raw []byte, req
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
logRespCompat(req.Model, compat)
|
logRespCompat(req.Model, compat)
|
||||||
|
web, x := h.gw.GrokSearch()
|
||||||
|
if injectedBody, injected := service.RespInjectGrokTools(body, req.Model, web, x); len(injected) > 0 {
|
||||||
|
body = injectedBody
|
||||||
|
log.Printf("responses 直通(model=%s): 默认注入 %s", req.Model, strings.Join(injected, ", "))
|
||||||
|
}
|
||||||
if req.Stream {
|
if req.Stream {
|
||||||
if len(body) > service.RespStreamUpgradeLimit {
|
if on, kb := h.gw.StreamGuard(); on && service.RespGuardBytes(body) > kb*1024 {
|
||||||
h.responsesStreamUpgrade(c, body, req, compat)
|
h.responsesStreamUpgrade(c, body, req, compat)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -630,9 +635,9 @@ func logRespCompat(model string, compat service.RespCompat) {
|
|||||||
log.Printf("responses 直通(model=%s): %s", model, strings.Join(parts, "; "))
|
log.Printf("responses 直通(model=%s): %s", model, strings.Join(parts, "; "))
|
||||||
}
|
}
|
||||||
|
|
||||||
// responsesStreamUpgrade 流式升级回退:上游对超大流式请求会在推理途中掐断
|
// responsesStreamUpgrade 流式升级回退:instructions+tools 合计超过保险丝阈值
|
||||||
// (实测 >~82KB,见 RespStreamUpgradeLimit),超限时改调非流式上游拿完整响应,
|
// (设置页 AI Tab 配置,默认开 60KB;上游对 >≈64.5KB 会静默断流)时改调非流式
|
||||||
// 本地合成最小 SSE 事件序列回给客户端;丢失增量输出,换会话不中断。
|
// 上游拿完整响应,本地合成最小 SSE 事件序列回给客户端;丢失增量输出,换会话不中断。
|
||||||
func (h *aiGatewayHandler) responsesStreamUpgrade(c *gin.Context, body []byte, req aiwire.RespRequest, compat service.RespCompat) {
|
func (h *aiGatewayHandler) responsesStreamUpgrade(c *gin.Context, body []byte, req aiwire.RespRequest, compat service.RespCompat) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
nsBody, err := service.RespDisableStream(body)
|
nsBody, err := service.RespDisableStream(body)
|
||||||
@@ -640,7 +645,8 @@ func (h *aiGatewayHandler) responsesStreamUpgrade(c *gin.Context, body []byte, r
|
|||||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("responses 直通(model=%s): 请求体 %dKB 超流式安全上限,改走非流式合成 SSE", req.Model, len(body)/1024)
|
log.Printf("responses 直通(model=%s): instructions+tools %dKB 超保险丝阈值,改走非流式合成 SSE",
|
||||||
|
req.Model, service.RespGuardBytes(body)/1024)
|
||||||
payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), nsBody, req.Model, keyGroup(c))
|
payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), nsBody, req.Model, keyGroup(c))
|
||||||
entry := h.logEntry(c, "responses", req.Model, true, meta, start)
|
entry := h.logEntry(c, "responses", req.Model, true, meta, start)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ func registerAiAdmin(secured *gin.RouterGroup, aiGateway *service.AiGatewayServi
|
|||||||
secured.GET("/ai-channels/:id/models", aiadmin.listChannelModels)
|
secured.GET("/ai-channels/:id/models", aiadmin.listChannelModels)
|
||||||
secured.POST("/ai-channels/:id/test-model", aiadmin.testChannelModel)
|
secured.POST("/ai-channels/:id/test-model", aiadmin.testChannelModel)
|
||||||
secured.GET("/ai-models", aiadmin.gatewayModels)
|
secured.GET("/ai-models", aiadmin.gatewayModels)
|
||||||
|
secured.GET("/ai-model-catalog", aiadmin.modelCatalog)
|
||||||
secured.GET("/ai-settings", aiadmin.aiSettings)
|
secured.GET("/ai-settings", aiadmin.aiSettings)
|
||||||
secured.PUT("/ai-settings", aiadmin.updateAiSettings)
|
secured.PUT("/ai-settings", aiadmin.updateAiSettings)
|
||||||
secured.GET("/ai-blacklist", aiadmin.listBlacklist)
|
secured.GET("/ai-blacklist", aiadmin.listBlacklist)
|
||||||
|
|||||||
@@ -400,6 +400,7 @@ func fillInstanceIPs(ctx context.Context, cc core.ComputeClient, vn core.Virtual
|
|||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
sem = make(chan struct{}, 8)
|
sem = make(chan struct{}, 8)
|
||||||
|
primarySeen = make(map[*Instance]bool)
|
||||||
)
|
)
|
||||||
for _, att := range attResp.Items {
|
for _, att := range attResp.Items {
|
||||||
inst, ok := active[deref(att.InstanceId)]
|
inst, ok := active[deref(att.InstanceId)]
|
||||||
@@ -416,16 +417,30 @@ func fillInstanceIPs(ctx context.Context, cc core.ComputeClient, vn core.Virtual
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
inst.SubnetID = deref(vnicResp.SubnetId)
|
applyVnicAddrs(inst, vnicResp.Vnic, primarySeen)
|
||||||
inst.PrivateIP = deref(vnicResp.PrivateIp)
|
|
||||||
inst.PublicIP = deref(vnicResp.PublicIp)
|
|
||||||
inst.Ipv6Addresses = vnicResp.Ipv6Addresses
|
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
}(att.VnicId, inst)
|
}(att.VnicId, inst)
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyVnicAddrs 将 VNIC 地址写入实例。多网卡实例以主网卡为准:
|
||||||
|
// 主网卡返回前先用先到的网卡兜底,主网卡到达后覆盖并锁定,避免并发
|
||||||
|
// 完成顺序决定展示结果。调用方需持有保护 inst 与 primarySeen 的锁。
|
||||||
|
func applyVnicAddrs(inst *Instance, v core.Vnic, primarySeen map[*Instance]bool) {
|
||||||
|
isPrimary := v.IsPrimary != nil && *v.IsPrimary
|
||||||
|
if !isPrimary && (primarySeen[inst] || inst.SubnetID != "") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inst.SubnetID = deref(v.SubnetId)
|
||||||
|
inst.PrivateIP = deref(v.PrivateIp)
|
||||||
|
inst.PublicIP = deref(v.PublicIp)
|
||||||
|
inst.Ipv6Addresses = v.Ipv6Addresses
|
||||||
|
if isPrimary {
|
||||||
|
primarySeen[inst] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func toInstance(inst core.Instance) Instance {
|
func toInstance(inst core.Instance) Instance {
|
||||||
out := Instance{
|
out := Instance{
|
||||||
ID: deref(inst.Id),
|
ID: deref(inst.Id),
|
||||||
|
|||||||
@@ -5,9 +5,51 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
"github.com/oracle/oci-go-sdk/v65/core"
|
"github.com/oracle/oci-go-sdk/v65/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestApplyVnicAddrs(t *testing.T) {
|
||||||
|
primary := core.Vnic{
|
||||||
|
IsPrimary: common.Bool(true),
|
||||||
|
SubnetId: common.String("sub-a"),
|
||||||
|
PrivateIp: common.String("10.0.0.2"),
|
||||||
|
PublicIp: common.String("1.1.1.1"),
|
||||||
|
}
|
||||||
|
secondary := core.Vnic{
|
||||||
|
IsPrimary: common.Bool(false),
|
||||||
|
SubnetId: common.String("sub-b"),
|
||||||
|
PrivateIp: common.String("10.0.0.9"),
|
||||||
|
}
|
||||||
|
secondary2 := core.Vnic{
|
||||||
|
SubnetId: common.String("sub-c"),
|
||||||
|
PrivateIp: common.String("10.0.0.7"),
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
order []core.Vnic
|
||||||
|
wantPrivate string
|
||||||
|
wantSubnet string
|
||||||
|
}{
|
||||||
|
{name: "主卡先到不被次卡覆盖", order: []core.Vnic{primary, secondary}, wantPrivate: "10.0.0.2", wantSubnet: "sub-a"},
|
||||||
|
{name: "次卡先兜底主卡后覆盖", order: []core.Vnic{secondary, primary}, wantPrivate: "10.0.0.2", wantSubnet: "sub-a"},
|
||||||
|
{name: "无主卡时先到者保留", order: []core.Vnic{secondary, secondary2}, wantPrivate: "10.0.0.9", wantSubnet: "sub-b"},
|
||||||
|
{name: "仅次卡也能兜底", order: []core.Vnic{secondary2}, wantPrivate: "10.0.0.7", wantSubnet: "sub-c"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
inst := &Instance{}
|
||||||
|
seen := make(map[*Instance]bool)
|
||||||
|
for _, v := range tt.order {
|
||||||
|
applyVnicAddrs(inst, v, seen)
|
||||||
|
}
|
||||||
|
if inst.PrivateIP != tt.wantPrivate || inst.SubnetID != tt.wantSubnet {
|
||||||
|
t.Errorf("got (%q, %q), want (%q, %q)", inst.PrivateIP, inst.SubnetID, tt.wantPrivate, tt.wantSubnet)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestShellSingleQuote(t *testing.T) {
|
func TestShellSingleQuote(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
+135
-12
@@ -10,6 +10,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -61,20 +62,72 @@ type AiGatewayService struct {
|
|||||||
onChannelsChanged func(context.Context)
|
onChannelsChanged func(context.Context)
|
||||||
// filterDeprecated 是「过滤弃用模型」开关(内存镜像,持久化在 settings 表)
|
// filterDeprecated 是「过滤弃用模型」开关(内存镜像,持久化在 settings 表)
|
||||||
filterDeprecated atomic.Bool
|
filterDeprecated atomic.Bool
|
||||||
|
// streamGuard* 是 Responses 流式保险丝(instructions+tools 合计超阈值改非流式)
|
||||||
|
streamGuardEnabled atomic.Bool
|
||||||
|
streamGuardKB atomic.Int64
|
||||||
|
// grokWebSearch / grokXSearch 是 xai. 模型服务端搜索工具默认注入开关
|
||||||
|
grokWebSearch atomic.Bool
|
||||||
|
grokXSearch atomic.Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAiGatewayService 组装依赖;调用 StartCleanup 后开始调用日志周期清理。
|
// NewAiGatewayService 组装依赖;调用 StartCleanup 后开始调用日志周期清理。
|
||||||
func NewAiGatewayService(db *gorm.DB, configs *OciConfigService, client oci.Client) *AiGatewayService {
|
func NewAiGatewayService(db *gorm.DB, configs *OciConfigService, client oci.Client) *AiGatewayService {
|
||||||
s := &AiGatewayService{db: db, configs: configs, client: client, lastTouch: map[uint]time.Time{}}
|
s := &AiGatewayService{db: db, configs: configs, client: client, lastTouch: map[uint]time.Time{}}
|
||||||
var row model.Setting
|
s.filterDeprecated.Store(loadBoolSetting(db, settingAiFilterDeprecated, false))
|
||||||
if err := db.Where("key = ?", settingAiFilterDeprecated).First(&row).Error; err == nil {
|
s.streamGuardEnabled.Store(loadBoolSetting(db, settingAiStreamGuardEnabled, true))
|
||||||
s.filterDeprecated.Store(row.Value == "1")
|
s.streamGuardKB.Store(int64(loadIntSetting(db, settingAiStreamGuardKB, defaultStreamGuardKB)))
|
||||||
}
|
s.grokWebSearch.Store(loadBoolSetting(db, settingAiGrokWebSearch, true))
|
||||||
|
s.grokXSearch.Store(loadBoolSetting(db, settingAiGrokXSearch, true))
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// settingAiFilterDeprecated 是「过滤弃用模型」开关的配置键,值 "1"/"0",缺省关闭。
|
// AI 网关运行时设置的配置键;bool 值存 "1"/"0"。
|
||||||
const settingAiFilterDeprecated = "ai_filter_deprecated"
|
const (
|
||||||
|
// settingAiFilterDeprecated 是「过滤弃用模型」开关,缺省关闭。
|
||||||
|
settingAiFilterDeprecated = "ai_filter_deprecated"
|
||||||
|
// settingAiStreamGuardEnabled / settingAiStreamGuardKB 是流式保险丝开关与
|
||||||
|
// 阈值(KB),缺省开、60(上游对 instructions+tools >≈64.5KB 流式静默断流)。
|
||||||
|
settingAiStreamGuardEnabled = "ai_stream_guard_enabled"
|
||||||
|
settingAiStreamGuardKB = "ai_stream_guard_kb"
|
||||||
|
// settingAiGrokWebSearch / settingAiGrokXSearch 是 grok 搜索工具默认注入
|
||||||
|
// 开关,缺省开。
|
||||||
|
settingAiGrokWebSearch = "ai_grok_web_search"
|
||||||
|
settingAiGrokXSearch = "ai_grok_x_search"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultStreamGuardKB 是保险丝阈值缺省值,低于实测断流边界留余量。
|
||||||
|
const defaultStreamGuardKB = 60
|
||||||
|
|
||||||
|
// loadBoolSetting 读 settings 表布尔键,无行或值非法时返回缺省。
|
||||||
|
func loadBoolSetting(db *gorm.DB, key string, def bool) bool {
|
||||||
|
var row model.Setting
|
||||||
|
if err := db.Where("key = ?", key).First(&row).Error; err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return row.Value == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadIntSetting 读 settings 表整数键,无行或解析失败时返回缺省。
|
||||||
|
func loadIntSetting(db *gorm.DB, key string, def int) int {
|
||||||
|
var row model.Setting
|
||||||
|
if err := db.Where("key = ?", key).First(&row).Error; err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(row.Value)
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveBoolSetting 持久化布尔键。
|
||||||
|
func (s *AiGatewayService) saveBoolSetting(ctx context.Context, key string, on bool) error {
|
||||||
|
value := "0"
|
||||||
|
if on {
|
||||||
|
value = "1"
|
||||||
|
}
|
||||||
|
return s.db.WithContext(ctx).Save(&model.Setting{Key: key, Value: value}).Error
|
||||||
|
}
|
||||||
|
|
||||||
// FilterDeprecated 返回「过滤弃用模型」开关状态。
|
// FilterDeprecated 返回「过滤弃用模型」开关状态。
|
||||||
func (s *AiGatewayService) FilterDeprecated() bool { return s.filterDeprecated.Load() }
|
func (s *AiGatewayService) FilterDeprecated() bool { return s.filterDeprecated.Load() }
|
||||||
@@ -82,18 +135,54 @@ func (s *AiGatewayService) FilterDeprecated() bool { return s.filterDeprecated.L
|
|||||||
// SetFilterDeprecated 持久化并即时生效开关:开启后已宣布弃用
|
// SetFilterDeprecated 持久化并即时生效开关:开启后已宣布弃用
|
||||||
// (deprecated_at 非空,即使未退役)的模型从列表与路由中排除。
|
// (deprecated_at 非空,即使未退役)的模型从列表与路由中排除。
|
||||||
func (s *AiGatewayService) SetFilterDeprecated(ctx context.Context, on bool) error {
|
func (s *AiGatewayService) SetFilterDeprecated(ctx context.Context, on bool) error {
|
||||||
value := "0"
|
if err := s.saveBoolSetting(ctx, settingAiFilterDeprecated, on); err != nil {
|
||||||
if on {
|
|
||||||
value = "1"
|
|
||||||
}
|
|
||||||
err := s.db.WithContext(ctx).Save(&model.Setting{Key: settingAiFilterDeprecated, Value: value}).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("保存过滤弃用模型开关: %w", err)
|
return fmt.Errorf("保存过滤弃用模型开关: %w", err)
|
||||||
}
|
}
|
||||||
s.filterDeprecated.Store(on)
|
s.filterDeprecated.Store(on)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StreamGuard 返回流式保险丝开关与阈值(KB)。
|
||||||
|
func (s *AiGatewayService) StreamGuard() (bool, int) {
|
||||||
|
return s.streamGuardEnabled.Load(), int(s.streamGuardKB.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStreamGuard 持久化并即时生效流式保险丝;kb 限定 1..1024。
|
||||||
|
func (s *AiGatewayService) SetStreamGuard(ctx context.Context, on bool, kb int) error {
|
||||||
|
if kb < 1 || kb > 1024 {
|
||||||
|
return fmt.Errorf("流式保险丝阈值须在 1..1024 KB, 收到 %d", kb)
|
||||||
|
}
|
||||||
|
if err := s.saveBoolSetting(ctx, settingAiStreamGuardEnabled, on); err != nil {
|
||||||
|
return fmt.Errorf("保存流式保险丝开关: %w", err)
|
||||||
|
}
|
||||||
|
err := s.db.WithContext(ctx).
|
||||||
|
Save(&model.Setting{Key: settingAiStreamGuardKB, Value: strconv.Itoa(kb)}).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("保存流式保险丝阈值: %w", err)
|
||||||
|
}
|
||||||
|
s.streamGuardEnabled.Store(on)
|
||||||
|
s.streamGuardKB.Store(int64(kb))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrokSearch 返回 grok 服务端搜索工具默认注入开关(web_search, x_search)。
|
||||||
|
func (s *AiGatewayService) GrokSearch() (bool, bool) {
|
||||||
|
return s.grokWebSearch.Load(), s.grokXSearch.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetGrokSearch 持久化并即时生效 grok 搜索工具默认注入开关。
|
||||||
|
func (s *AiGatewayService) SetGrokSearch(ctx context.Context, web, x bool) error {
|
||||||
|
if err := s.saveBoolSetting(ctx, settingAiGrokWebSearch, web); err != nil {
|
||||||
|
return fmt.Errorf("保存 grok web_search 开关: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.saveBoolSetting(ctx, settingAiGrokXSearch, x); err != nil {
|
||||||
|
return fmt.Errorf("保存 grok x_search 开关: %w", err)
|
||||||
|
}
|
||||||
|
s.grokWebSearch.Store(web)
|
||||||
|
s.grokXSearch.Store(x)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// SetOnChannelsChanged 注册渠道数量变化钩子(渠道创建/删除成功后调用)。
|
// SetOnChannelsChanged 注册渠道数量变化钩子(渠道创建/删除成功后调用)。
|
||||||
func (s *AiGatewayService) SetOnChannelsChanged(fn func(context.Context)) {
|
func (s *AiGatewayService) SetOnChannelsChanged(fn func(context.Context)) {
|
||||||
s.onChannelsChanged = fn
|
s.onChannelsChanged = fn
|
||||||
@@ -661,6 +750,40 @@ func (s *AiGatewayService) GatewayModels(ctx context.Context, group string) (aiw
|
|||||||
return list, nil
|
return list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AggregatedModel 是聚合模型目录条目(设置页黑名单添加弹窗用)。
|
||||||
|
type AggregatedModel struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Capability string `json:"capability"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AggregatedModels 返回启用渠道去重后的模型目录(含能力);空能力归一为 CHAT。
|
||||||
|
// 与模型列表口径一致:「过滤弃用」开启时弃用模型不出现在目录中。
|
||||||
|
func (s *AiGatewayService) AggregatedModels(ctx context.Context) ([]AggregatedModel, error) {
|
||||||
|
q := s.db.WithContext(ctx).
|
||||||
|
Joins("JOIN ai_channels ON ai_channels.id = ai_model_caches.channel_id AND ai_channels.enabled = ?", true)
|
||||||
|
if s.FilterDeprecated() {
|
||||||
|
q = q.Where("ai_model_caches.deprecated_at IS NULL")
|
||||||
|
}
|
||||||
|
var rows []model.AiModelCache
|
||||||
|
if err := q.Order("ai_model_caches.name ASC").Find(&rows).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("聚合模型目录: %w", err)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := []AggregatedModel{}
|
||||||
|
for _, r := range rows {
|
||||||
|
if seen[r.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[r.Name] = true
|
||||||
|
cap := r.Capability
|
||||||
|
if cap == "" {
|
||||||
|
cap = "CHAT"
|
||||||
|
}
|
||||||
|
out = append(out, AggregatedModel{Name: r.Name, Capability: cap})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeprecatingModels 返回 within 窗口内即将退役或即将弃用的在池模型(按名称去重):
|
// DeprecatingModels 返回 within 窗口内即将退役或即将弃用的在池模型(按名称去重):
|
||||||
// 退役(TimeOnDemandRetired)才导致不可调用,单独标注;已过弃用日但未到退役日的
|
// 退役(TimeOnDemandRetired)才导致不可调用,单独标注;已过弃用日但未到退役日的
|
||||||
// 模型仍可正常调用,不再反复告警;已过退役日的在同步层剔除,不会出现在池中。
|
// 模型仍可正常调用,不再反复告警;已过退役日的在同步层剔除,不会出现在池中。
|
||||||
|
|||||||
@@ -1060,3 +1060,67 @@ func TestRespPassthroughStreamSwitchesChannel(t *testing.T) {
|
|||||||
t.Errorf("流内容未透传: %s", payload)
|
t.Errorf("流内容未透传: %s", payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAiRuntimeSettings 断言流式保险丝与 grok 注入开关的缺省值、往返与持久化。
|
||||||
|
func TestAiRuntimeSettings(t *testing.T) {
|
||||||
|
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if on, kb := gw.StreamGuard(); !on || kb != 60 {
|
||||||
|
t.Fatalf("保险丝缺省应为 开/60, got %v/%d", on, kb)
|
||||||
|
}
|
||||||
|
if web, x := gw.GrokSearch(); !web || !x {
|
||||||
|
t.Fatalf("grok 注入缺省应双开, got %v/%v", web, x)
|
||||||
|
}
|
||||||
|
for _, bad := range []int{0, -1, 1025} {
|
||||||
|
if err := gw.SetStreamGuard(ctx, true, bad); err == nil {
|
||||||
|
t.Errorf("阈值 %d 应报错", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := gw.SetStreamGuard(ctx, false, 80); err != nil {
|
||||||
|
t.Fatalf("SetStreamGuard: %v", err)
|
||||||
|
}
|
||||||
|
if err := gw.SetGrokSearch(ctx, false, true); err != nil {
|
||||||
|
t.Fatalf("SetGrokSearch: %v", err)
|
||||||
|
}
|
||||||
|
gw2 := NewAiGatewayService(gw.db, svc, &gatewayStubClient{})
|
||||||
|
if on, kb := gw2.StreamGuard(); on || kb != 80 {
|
||||||
|
t.Errorf("重建后保险丝应为 关/80, got %v/%d", on, kb)
|
||||||
|
}
|
||||||
|
if web, x := gw2.GrokSearch(); web || !x {
|
||||||
|
t.Errorf("重建后 grok 注入应为 关/开, got %v/%v", web, x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAggregatedModelsFilterDeprecated 断言聚合目录与模型列表口径一致:
|
||||||
|
// 「过滤弃用」开启时弃用模型不出现,关闭时出现;空能力归一为 CHAT。
|
||||||
|
func TestAggregatedModelsFilterDeprecated(t *testing.T) {
|
||||||
|
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
|
||||||
|
dep := time.Now().Add(-24 * time.Hour)
|
||||||
|
old := &model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..dep", Name: "meta.llama-old",
|
||||||
|
Vendor: "meta", SyncedAt: time.Now(), DeprecatedAt: &dep}
|
||||||
|
if err := gw.db.Create(old).Error; err != nil {
|
||||||
|
t.Fatalf("seed deprecated cache: %v", err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
items, err := gw.AggregatedModels(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AggregatedModels: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 2 || items[0].Capability == "" {
|
||||||
|
t.Fatalf("开关关:应含弃用模型且能力归一, got %+v", items)
|
||||||
|
}
|
||||||
|
if err := gw.SetFilterDeprecated(ctx, true); err != nil {
|
||||||
|
t.Fatalf("SetFilterDeprecated: %v", err)
|
||||||
|
}
|
||||||
|
items, err = gw.AggregatedModels(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AggregatedModels(过滤): %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 || items[0].Name == "meta.llama-old" {
|
||||||
|
t.Fatalf("开关开:弃用模型应被过滤, got %+v", items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -320,10 +320,63 @@ func respToolDrop(tool map[string]any) (string, bool) {
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// RespStreamUpgradeLimit 是流式直通的请求体安全上限。实测(2026-07,xai 面):
|
// RespGuardBytes 返回请求体中 instructions 与 tools 两字段的原始字节数之和,
|
||||||
// 超过 ~82KB 的流式请求上游会在推理阶段掐断流(非流式不受影响,纯体积触发,
|
// 流式保险丝据此判定。上游对二者合计 >≈64.5KB 的流式请求会在推理阶段静默断流
|
||||||
// 与工具构成无关),预留余量取 76KB;超限时网关改走非流式上游并合成 SSE。
|
// (纯 EOF,input 正文不计入;2026-07-16 实测仍存在),阈值由设置页 AI Tab 配置。
|
||||||
const RespStreamUpgradeLimit = 76 * 1024
|
// 解析失败返回 0(放行,交由上游正常报错)。
|
||||||
|
func RespGuardBytes(body []byte) int {
|
||||||
|
var probe struct {
|
||||||
|
Instructions json.RawMessage `json:"instructions"`
|
||||||
|
Tools json.RawMessage `json:"tools"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &probe); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(probe.Instructions) + len(probe.Tools)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RespInjectGrokTools 为 xai. 前缀模型默认注入服务端搜索工具:开关开启且请求
|
||||||
|
// tools 中不存在同名工具时追加 {"type":"web_search"} / {"type":"x_search"};
|
||||||
|
// 已存在(含任意参数形态)不覆盖。返回改写后 body 与注入清单(观测日志用);
|
||||||
|
// 模型不匹配、两开关全关或解析失败时原样返回。
|
||||||
|
func RespInjectGrokTools(body []byte, model string, web, x bool) ([]byte, []string) {
|
||||||
|
if !strings.HasPrefix(model, "xai.") || (!web && !x) {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(strings.NewReader(string(body)))
|
||||||
|
dec.UseNumber()
|
||||||
|
var m map[string]any
|
||||||
|
if err := dec.Decode(&m); err != nil {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
tools, _ := m["tools"].([]any)
|
||||||
|
missing := map[string]bool{"web_search": web, "x_search": x}
|
||||||
|
for _, t := range tools {
|
||||||
|
tool, ok := t.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if typ, _ := tool["type"].(string); missing[typ] {
|
||||||
|
missing[typ] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var injected []string
|
||||||
|
for _, typ := range []string{"web_search", "x_search"} {
|
||||||
|
if missing[typ] {
|
||||||
|
tools = append(tools, map[string]any{"type": typ})
|
||||||
|
injected = append(injected, typ)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(injected) == 0 {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
m["tools"] = tools
|
||||||
|
out, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
return out, injected
|
||||||
|
}
|
||||||
|
|
||||||
// RespDisableStream 把请求体的 stream 改为 false(流式升级回退用),其余字段
|
// RespDisableStream 把请求体的 stream 改为 false(流式升级回退用),其余字段
|
||||||
// 原样保留。
|
// 原样保留。
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -329,6 +330,71 @@ func TestRespStreamCompletedUsage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRespGuardBytes 断言保险丝计量:instructions+tools 原始字节和,缺字段计 0,
|
||||||
|
// 解析失败返回 0 放行。
|
||||||
|
func TestRespGuardBytes(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, body string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"双字段", `{"instructions":"abcd","tools":[{"type":"web_search"}],"input":"xxxxxxxx"}`,
|
||||||
|
len(`"abcd"`) + len(`[{"type":"web_search"}]`)},
|
||||||
|
{"仅 instructions", `{"instructions":"abcd"}`, len(`"abcd"`)},
|
||||||
|
{"均缺失 input 不计", `{"input":"xxxxxxxxxxxxxxxx"}`, 0},
|
||||||
|
{"解析失败放行", `not-json`, 0},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := RespGuardBytes([]byte(tc.body)); got != tc.want {
|
||||||
|
t.Fatalf("RespGuardBytes() = %d, want %d", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRespInjectGrokTools 断言 grok 搜索工具默认注入:仅 xai. 模型、开关可控、
|
||||||
|
// 已带同名工具不覆盖、注入清单正确。
|
||||||
|
func TestRespInjectGrokTools(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, body, model string
|
||||||
|
web, x bool
|
||||||
|
wantInjected []string
|
||||||
|
wantContains []string
|
||||||
|
}{
|
||||||
|
{"非 xai 不注入", `{"tools":[]}`, "meta.llama-3.3", true, true, nil, nil},
|
||||||
|
{"双开无 tools 字段注入两个", `{"model":"xai.grok-4.3"}`, "xai.grok-4.3", true, true,
|
||||||
|
[]string{"web_search", "x_search"}, []string{`"web_search"`, `"x_search"`}},
|
||||||
|
{"已带 web_search 只注入 x_search", `{"tools":[{"type":"web_search","filters":{"x":1}}]}`,
|
||||||
|
"xai.grok-4.3", true, true, []string{"x_search"}, []string{`"filters"`}},
|
||||||
|
{"开关全关不注入", `{"tools":[]}`, "xai.grok-4.3", false, false, nil, nil},
|
||||||
|
{"仅开 x_search", `{"tools":[]}`, "xai.grok-4.3", false, true, []string{"x_search"}, nil},
|
||||||
|
{"解析失败原样", `not-json`, "xai.grok-4.3", true, true, nil, nil},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
out, injected := RespInjectGrokTools([]byte(tc.body), tc.model, tc.web, tc.x)
|
||||||
|
if fmt.Sprint(injected) != fmt.Sprint(tc.wantInjected) {
|
||||||
|
t.Fatalf("injected = %v, want %v", injected, tc.wantInjected)
|
||||||
|
}
|
||||||
|
if len(injected) == 0 && string(out) != tc.body {
|
||||||
|
t.Fatalf("未注入时应原样返回: %s", out)
|
||||||
|
}
|
||||||
|
for _, sub := range append(tc.wantContains, toolTypes(injected)...) {
|
||||||
|
if !strings.Contains(string(out), sub) {
|
||||||
|
t.Fatalf("输出缺少 %s: %s", sub, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toolTypes 把注入清单转为输出应包含的片段断言。
|
||||||
|
func toolTypes(injected []string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, typ := range injected {
|
||||||
|
out = append(out, `{"type":"`+typ+`"}`)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// TestRespDisableStream 断言流式升级回退把 stream 置 false 且其余字段保留。
|
// TestRespDisableStream 断言流式升级回退把 stream 置 false 且其余字段保留。
|
||||||
func TestRespDisableStream(t *testing.T) {
|
func TestRespDisableStream(t *testing.T) {
|
||||||
out, err := RespDisableStream([]byte(`{"model":"m","stream":true,"max_output_tokens":64}`))
|
out, err := RespDisableStream([]byte(`{"model":"m","stream":true,"max_output_tokens":64}`))
|
||||||
|
|||||||
Reference in New Issue
Block a user