Compare commits
5
Commits
v0.3.0
...
79c9e4d9b9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79c9e4d9b9 | ||
|
|
2fea315430 | ||
|
|
b4ef98a25e | ||
|
|
0a86b5a291 | ||
|
|
9309ad1ffc |
@@ -1,6 +1,6 @@
|
|||||||
# Backend Development Guidelines(oci-portal 后端规范)
|
# Backend Development Guidelines(oci-portal 后端规范)
|
||||||
|
|
||||||
> Go 后端(`oci-portal/`)编码规范入口。规范提炼自 Google / Uber Go Style Guide、Effective Go 与 Go 官方模块布局指南,按本项目裁剪;条目与项目约定冲突时,以本 spec 为准。迁自 docs/开发指南.md 与根目录 AGENTS.md(2026-07)。
|
> Go 后端(`oci-portal/`)编码规范入口。规范提炼自 Google / Uber Go Style Guide、Effective Go 与 Go 官方模块布局指南,按本项目裁剪;条目与项目约定冲突时,以本 spec 为准。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ go mod tidy # 依赖有变更时
|
|||||||
全部 HTTP 接口带 swaggo 注释(@Summary 中文/@Tags 按域/@Param/@Success/@Router;JWT 组接口标 @Security BearerAuth)。**新增或修改接口必须同步注释**,并重新生成 spec:
|
全部 HTTP 接口带 swaggo 注释(@Summary 中文/@Tags 按域/@Param/@Success/@Router;JWT 组接口标 @Security BearerAuth)。**新增或修改接口必须同步注释**,并重新生成 spec:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go tool swag init -g cmd/server/main.go -o docs --parseInternal --parseDependency
|
go tool swag init -g cmd/server/main.go -o docs --parseInternal --parseDependency --overridesFile docs/.swaggo
|
||||||
```
|
```
|
||||||
|
|
||||||
生成的 `docs/` 一并提交;UI 由 `SWAGGER=1` 开启(/swagger/index.html)。同名 handler 方法(list/create/get/update/remove)分布在多个 struct 上,写注释时确认 @Router 路径与该 receiver 的真实注册一致(routes_*.go)。
|
生成的 `docs/` 一并提交;UI 由 `SWAGGER=1` 开启(/swagger/index.html)。同名 handler 方法(list/create/get/update/remove)分布在多个 struct 上,写注释时确认 @Router 路径与该 receiver 的真实注册一致(routes_*.go)。
|
||||||
|
|||||||
+51
-5
@@ -1,8 +1,54 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
|
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。
|
||||||
|
|
||||||
## [0.3.0] - 2026-07-13
|
## [0.5.1]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 修复外部身份(OAuth2)登录 / 绑定无系统日志的问题:回调成功记 200(用户名为面板账号)、失败记 401(附失败原因);此前回调为 GET 请求不经写操作日志中间件,完全无记录
|
||||||
|
|
||||||
|
## [0.5.0]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- xAI 官方格式文本转语音端点 `POST /ai/v1/tts`:接受 xAI 官方 TTS 请求(`text` / `language` 必填,`voice_id`、`output_format`{codec, sample_rate, bit_rate}、`speed` 等),网关转换为 OpenAI 兼容形态后与 `/ai/v1/audio/speech` 走同一上游与渠道调度;`model` 为网关扩展字段(缺省 `xai.grok-tts`);实测 `output_format` 对象与 `speed` 透传生效,xAI SDK / 客户端可直接指向网关
|
||||||
|
- 「过滤弃用模型」开关(`GET` / `PUT /api/v1/ai-settings`,持久化):开启后 OCI 已宣布弃用(即使未到退役日)的模型从模型列表与路由中同时排除,关闭恢复;渠道同步入库与 30 天退役提醒不受影响
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- AI 网关文档更名为 `docs/AI网关.md`(原 `docs/ai-gateway.md`),README 引用同步
|
||||||
|
|
||||||
|
## [0.4.0]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 文本转语音端点 `POST /ai/v1/audio/speech`:OpenAI Audio Speech 兼容,直通 OCI 兼容面(模型 `xai.grok-tts`,voice 取 xAI Grok Voice 列表 `ara`/`eve`/`leo`/`rex`/`sal`);上游必填的 `language` 缺省时自动注入 `"auto"`,xAI 专属参数平铺透传;响应为一次性完整音频(Content-Type 透传上游,缺省 audio/mpeg),不提供流式
|
||||||
|
- 文档重排端点 `POST /ai/v1/rerank`:Jina / Cohere 通行协议,走 OCI typed 面(`cohere.rerank-v4.0-pro` / `-fast`),支持 `top_n` 与 `return_documents`,`results[].index` 指向入参 `documents` 下标
|
||||||
|
- 内容审核端点 `POST /ai/v1/moderations`:OpenAI moderations 外壳映射 OCI Guardrails(内容审核 / PII / 提示注入),`input` 为字符串或字符串数组(单次至多 8 条),categories 用 OCI 原生维度 `overall` / `blocklist` / `prompt_injection`(任一得分 ≥0.5 判 `flagged`),PII 命中放扩展字段 `results[].pii`(不参与 flagged;实测中文人名 / 手机号识别较弱,英文正常)
|
||||||
|
- Responses 服务端工具扩展:放行 Oracle 文档化的 xAI `code_interpreter` 与远程 `mcp` 工具,并解除 `web_search` / `x_search` 仅非流式的限制——四类服务端工具非流式与流式均实测可用;`code_interpreter` 的命名容器管理与 File Search 不提供
|
||||||
|
- 模型能力映射扩展:`TEXT_RERANK` → RERANK、`TEXT_TO_AUDIO` → TTS,渠道探测 / 同步自动发现重排与语音模型入池
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- AI 网关文档独立成篇 `docs/ai-gateway.md`(端点定位、兼容边界、已知上游限制、字段兼容矩阵——矩阵只列支持项,不支持 / 忽略项以文字简述),README 精简为概览并引用;实测披露:Responses `input_file` 内容块被上游以 ZDR 形态拒绝,能力不可用
|
||||||
|
- swagger 全面修缺:135 处响应注解从泛型 map 改为具体类型(补文档用途响应结构与泛型列表外壳),AI 网关端点请求 / 响应 schema 完整可见;`json.RawMessage` 与联合类型统一渲染为任意 JSON 值(AnyJSON),不再误显示为 byte 数组;swag overrides 配置移至 `docs/.swaggo`,生成命令加 `--overridesFile docs/.swaggo`;顺带修正网页控制台会话创建响应码(200→201)与 SAML 元数据下载(改文件响应)两处注解失真
|
||||||
|
|
||||||
|
## [0.3.1]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- AI 网关流式断流自动降级:Messages 与 Chat Completions 流式请求在客户端尚未收到任何输出时遭遇上游断流,自动降级为非流式重做,结果按标准事件 / chunk 序列一次推送,调用日志记 `retries=1` 与降级标记。实测 OCI 兼容面对 `instructions` 与 `tools` 合计超约 64.5KB 的流式请求会静默断连(无错误事件,非流式正常,消息正文不计入),Claude Code 等大体量系统提示客户端极易触发;Responses 直通因初始事件已转发无法透明降级,日志记「上游流提前终止」;README 增补「已知上游限制:大 system 区流式断流」小节
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Messages 的 `max_tokens` 改为可缺省:缺省或 ≤0 时按默认值 8192 放行(此前返回 400;部分客户端将该字段视为选填)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- AI 网关流式假成功:上游 error / `response.failed` 事件此前被吞掉,流提前 EOF 也被伪装成正常结束,调用日志呈现 200 · 0/0 且无错误信息;现 Messages 将上游失败转为 Anthropic `error` 事件,三个流式端点日志均记录上游错误消息或「上游流提前终止,未返回终态事件」
|
||||||
|
|
||||||
|
## [0.3.0]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
@@ -19,7 +65,7 @@
|
|||||||
|
|
||||||
- **移除自定义审计告警规则**及其阈值、窗口和冷却匹配能力;`/api/v1/log-events/alert-rules` 与 `/api/v1/log-events/alert-rules/{ruleId}` 管理接口、`audit_alert` 通知模板不再提供,已有规则升级后不再执行
|
- **移除自定义审计告警规则**及其阈值、窗口和冷却匹配能力;`/api/v1/log-events/alert-rules` 与 `/api/v1/log-events/alert-rules/{ruleId}` 管理接口、`audit_alert` 通知模板不再提供,已有规则升级后不再执行
|
||||||
|
|
||||||
## [0.2.0] - 2026-07-12
|
## [0.2.0]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
@@ -46,7 +92,7 @@
|
|||||||
- 渠道「同步模型成功但探测报错不可用」:法兰克福等区域的微调基座模型占满探测候选导致的误报(探测状态与真实可用性不符的根因)
|
- 渠道「同步模型成功但探测报错不可用」:法兰克福等区域的微调基座模型占满探测候选导致的误报(探测状态与真实可用性不符的根因)
|
||||||
- 租户删除:告警命中清理改子查询,日志事件数万条时不再超出 SQL 绑定变量上限导致删除失败;无法解析的任务 payload 记警告跳过并保留原任务,不再永久阻断租户删除
|
- 租户删除:告警命中清理改子查询,日志事件数万条时不再超出 SQL 绑定变量上限导致删除失败;无法解析的任务 payload 记警告跳过并保留原任务,不再永久阻断租户删除
|
||||||
|
|
||||||
## [0.1.0] - 2026-07-10
|
## [0.1.0]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
@@ -74,7 +120,7 @@
|
|||||||
- HTTP 入口加固:显式读写超时与优雅关闭、请求体上限(面板 1MB / AI 网关 10MB)、登录字段长度上限、登录守卫条目数有界
|
- HTTP 入口加固:显式读写超时与优雅关闭、请求体上限(面板 1MB / AI 网关 10MB)、登录字段长度上限、登录守卫条目数有界
|
||||||
- 出错响应脱敏:内部错误只返回固定文案 + requestId,完整原因写服务端日志;GORM SQL 日志参数化输出,不再记录实参
|
- 出错响应脱敏:内部错误只返回固定文案 + requestId,完整原因写服务端日志;GORM SQL 日志参数化输出,不再记录实参
|
||||||
|
|
||||||
## [0.0.1] - 2026-07-09
|
## [0.0.1]
|
||||||
|
|
||||||
首个版本:自托管 OCI 多租户管理面板后端。
|
首个版本:自托管 OCI 多租户管理面板后端。
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
v0.3.0
|
v0.5.1
|
||||||
|
|||||||
@@ -197,196 +197,21 @@ server {
|
|||||||
|
|
||||||
## AI 网关
|
## AI 网关
|
||||||
|
|
||||||
AI 网关使用面板创建的独立密钥鉴权,支持 `Authorization: Bearer sk-...` 和 `x-api-key: sk-...`。密钥可绑定渠道分组和模型白名单;全局模型黑名单会从模型列表、路由和探测候选中同时排除目标模型。
|
面板内置 OpenAI / Anthropic 兼容的 GenAI 网关:独立密钥鉴权(`Authorization: Bearer sk-...` 或 `x-api-key`),支持渠道分组、加权路由、熔断探测、模型黑白名单、内容日志与调用日志。
|
||||||
|
|
||||||
| 端点 | 定位 | 流式 |
|
| 端点 | 定位 | 流式 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `POST /ai/v1/responses` | OpenAI Responses,无状态主接口 | SSE;服务端工具除外 |
|
| `POST /ai/v1/responses` | OpenAI Responses,无状态主接口(xAI 服务端工具 / MCP) | SSE |
|
||||||
| `POST /ai/v1/chat/completions` | OpenAI Chat Completions,存量客户端兼容层 | SSE |
|
| `POST /ai/v1/chat/completions` | OpenAI Chat Completions 兼容层 | SSE |
|
||||||
| `POST /ai/v1/messages` | Anthropic Messages 转换层 | SSE |
|
| `POST /ai/v1/messages` | Anthropic Messages 转换层 | SSE |
|
||||||
| `POST /ai/v1/embeddings` | OpenAI Embeddings | 否 |
|
| `POST /ai/v1/embeddings` | OpenAI Embeddings | 否 |
|
||||||
|
| `POST /ai/v1/audio/speech` | 文本转语音(xAI Voice) | 否 |
|
||||||
|
| `POST /ai/v1/tts` | 文本转语音(xAI 官方格式) | 否 |
|
||||||
|
| `POST /ai/v1/rerank` | 文档重排(Cohere Rerank) | 否 |
|
||||||
|
| `POST /ai/v1/moderations` | 内容审核(OCI Guardrails) | 否 |
|
||||||
| `GET /ai/v1/models` | 当前密钥可见的模型列表 | 否 |
|
| `GET /ai/v1/models` | 当前密钥可见的模型列表 | 否 |
|
||||||
|
|
||||||
兼容边界:
|
协议兼容边界、已知上游限制与逐字段兼容矩阵见 **[AI 网关文档](./docs/AI网关.md)**。
|
||||||
|
|
||||||
- 对话请求统一转发 OCI OpenAI 兼容面,当前供给以 `xai.`、`meta.`、`openai.` 前缀模型为主;Cohere Embeddings 不受该对话模型范围影响
|
|
||||||
- 网关不保存会话历史,客户端需要携带完整上下文;Responses 拒绝非空 `previous_response_id`、非 `null` `conversation` 和 `background:true`
|
|
||||||
- Responses 支持 xAI Grok `web_search` / `x_search` 服务端工具,但仅限非流式请求
|
|
||||||
- Responses 的 `reasoning.effort`、Messages 的 `output_config.effort` 和 Chat Completions 的 `reasoning_effort` 会传给上游,实际档位和效果由模型决定
|
|
||||||
- Chat Completions 只承担协议转换与兼容修复;新能力优先在 Responses 和 Messages 提供
|
|
||||||
- 单次请求最多尝试三个渠道;可重试错误会切换渠道,流式响应建立后不会换渠道重试
|
|
||||||
|
|
||||||
这里提供的是兼容接口而非 OpenAI / Anthropic 协议的完整实现。OCI OpenAI 兼容面的部分行为来自实测,未见 Oracle 文档承诺,可能随上游调整。路由与鉴权定义以 [Swagger YAML](docs/swagger.yaml) 或运行时 Swagger UI 为准;无法由 OpenAPI 完整表达的兼容边界列于上方。
|
|
||||||
|
|
||||||
### 字段兼容矩阵
|
|
||||||
|
|
||||||
以下矩阵以 2026-07-13 的 [OpenAI Responses](https://developers.openai.com/api/reference/resources/responses/methods/create)、[Chat Completions](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create)、[Embeddings](https://developers.openai.com/api/reference/resources/embeddings/methods/create) 和 [Anthropic Messages](https://platform.claude.com/docs/en/api/messages/create) 为标准基线,并与当前实现逐项核对。
|
|
||||||
|
|
||||||
这是一份兼容性快照,不替代 Swagger。标准接口和 OCI 上游都可能变化,最终行为以当前版本代码与实测为准。
|
|
||||||
|
|
||||||
| 标记 | 含义 |
|
|
||||||
| :---: | --- |
|
|
||||||
| ✅ | 网关直接支持 |
|
|
||||||
| ➡️ | 网关原样透传;是否生效由 OCI 上游决定 |
|
|
||||||
| 🔄 | 网关进行字段或协议转换后支持 |
|
|
||||||
| ◐ | 部分支持、存在前置条件或语义降级 |
|
|
||||||
| ⚠️ | 请求可被接受,但字段会被忽略 |
|
|
||||||
| ❌ | 网关在请求到达上游前拒绝 |
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><code>POST /ai/v1/responses</code> 对比 OpenAI Responses</summary>
|
|
||||||
|
|
||||||
Responses 是“原始 JSON 直通 + 本地门禁”。除 `store` 外,网关不会重建请求体;未知顶层字段也会保留并送往 OCI。
|
|
||||||
|
|
||||||
| 标准字段 | 状态 | 网关行为 |
|
|
||||||
| --- | :---: | --- |
|
|
||||||
| `model` | ◐ | 必须非空,还要通过密钥模型白名单并匹配可用渠道;随后原样透传 |
|
|
||||||
| `input` | ➡️ | 支持标准的字符串或 item 数组,原始内容透传;网关不逐项保证 OCI 能处理所有 item 类型 |
|
|
||||||
| `instructions`、`max_output_tokens` | ➡️ | 类型可解析后原样透传,不做范围或模型能力校验 |
|
|
||||||
| `temperature`、`top_p`、`parallel_tool_calls` | ➡️ | 原样透传,不做取值范围校验 |
|
|
||||||
| `text` / `text.format` / `text.verbosity` | ➡️ | 整个原始对象透传;结构化输出是否可用由 OCI 模型决定 |
|
|
||||||
| `reasoning` | ➡️ | 整个原始对象透传;`effort` 不校验档位,`summary` 等字段不会被网关删除 |
|
|
||||||
| `tool_choice` | ➡️ | 任意 JSON 原样透传,本地不校验枚举或结构 |
|
|
||||||
| `context_management`、`include`、`max_tool_calls`、`metadata`、`moderation`、`prompt` | ➡️ | 网关未建模但会保留在原始请求中,由 OCI 决定是否接受 |
|
|
||||||
| `prompt_cache_key`、`prompt_cache_retention`、`safety_identifier`、`service_tier` | ➡️ | 原样透传,不代表 OCI 一定实现 OpenAI 的对应语义 |
|
|
||||||
| `stream_options`、`top_logprobs`、`truncation`、`user` | ➡️ | 原样透传,不做本地语义校验 |
|
|
||||||
| `store` | 🔄 | 无论客户端传什么,上游请求都强制改写为 `false` |
|
|
||||||
| `previous_response_id` | ❌ | 非空即返回 400;网关不保存历史响应 |
|
|
||||||
| `conversation` | ◐ | 省略或 `null` 可通过;任何非 `null` 值返回 400 |
|
|
||||||
| `background` | ◐ | `true` 返回 400;`false`、`null` 或省略时继续透传 |
|
|
||||||
| `stream` | ◐ | 普通请求和 `function` 工具支持 SSE;含 `web_search` / `x_search` 时拒绝流式 |
|
|
||||||
| `tools[].type=function` | ➡️ | 非流式和流式均可,工具对象原样透传 |
|
|
||||||
| `tools[].type=web_search` / `x_search` | ◐ | 使用 xAI 服务端工具语义且仅支持非流式;`x_search` 不是 OpenAI 标准工具 |
|
|
||||||
| 其他标准工具类型 | ❌ | `web_search_preview`、`file_search`、`computer`、`code_interpreter`、`image_generation`、`mcp`、`shell`、`custom` 等返回 400 |
|
|
||||||
| 其他未知顶层字段 | ➡️ | 只要整个请求是合法 JSON,字段名、结构和数值都会保留 |
|
|
||||||
|
|
||||||
响应边界:
|
|
||||||
|
|
||||||
- 非流式成功响应不做转换,OCI JSON 原样返回;usage 解析只用于面板调用日志
|
|
||||||
- SSE 事件逐行原样转发,不补 `data: [DONE]`,推理增量也不会被网关过滤
|
|
||||||
- 流建立前最多切换三个渠道;流建立后中断不重试,客户端可能只收到部分事件
|
|
||||||
- 未知模型返回 404、无渠道返回 503;上游错误会套入 OpenAI 风格错误体,不保证与标准 OpenAI 错误字段完全相同
|
|
||||||
|
|
||||||
实现依据:[`airesponses.go`](internal/service/airesponses.go) · [`responses.go`](internal/aiwire/responses.go) · [`aigateway.go`](internal/api/aigateway.go)
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><code>POST /ai/v1/chat/completions</code> 对比 OpenAI Chat Completions</summary>
|
|
||||||
|
|
||||||
Chat Completions 会先转换为 Responses 请求,再把 OCI Responses 响应桥接回 Chat Completions 形态。
|
|
||||||
|
|
||||||
| 标准字段 | 状态 | 网关行为 |
|
|
||||||
| --- | :---: | --- |
|
|
||||||
| `model` | ◐ | 必填,受密钥白名单和可用渠道限制;模型名保留到上游请求 |
|
|
||||||
| `messages` | 🔄 | 必填并转换为 Responses `input` / `instructions` |
|
|
||||||
| `system` / `developer` 消息 | ◐ | 文本按出现顺序合并为 `instructions`;块数组中的非文本内容被忽略 |
|
|
||||||
| `user` / `assistant` 文本内容 | 🔄 | 字符串及 `text` 块分别转为 `input_text` / `output_text` |
|
|
||||||
| `image_url` 内容块 | ◐ | URL 或 data URI 转为 `input_image`;`image_url.detail` 被忽略 |
|
|
||||||
| `input_audio`、`file`、`refusal` 等内容块 | ❌ | 当前消息转换器不支持,返回 400 |
|
|
||||||
| assistant `tool_calls` / `role=tool` | 🔄 | 转为 `function_call` / `function_call_output`,保留调用 ID、函数名和参数 |
|
|
||||||
| 消息 `name`、`refusal`、`audio`、旧式 `function_call` | ⚠️ | 当前消息结构未建模,静默忽略 |
|
|
||||||
| `max_completion_tokens` | 🔄 | 转为 `max_output_tokens`,优先于 `max_tokens` |
|
|
||||||
| `max_tokens` | 🔄 | 未提供 `max_completion_tokens` 时转为 `max_output_tokens` |
|
|
||||||
| `temperature`、`top_p`、`parallel_tool_calls` | ◐ | 原值写入 Responses 请求,但不校验范围或模型能力 |
|
|
||||||
| `stream` | 🔄 | OCI Responses SSE 桥接为 `chat.completion.chunk`,末尾补 `data: [DONE]` |
|
|
||||||
| `stream_options.include_usage` | 🔄 | 控制网关在终块后追加 `choices: []` 的 usage 块 |
|
|
||||||
| `stream_options.include_obfuscation` | ⚠️ | 未建模,静默忽略 |
|
|
||||||
| `tools[].type=function` | ◐ | `name`、`description`、`parameters` 支持;`function.strict` 被忽略 |
|
|
||||||
| `tools[].type=custom` 及其他工具 | ❌ | 只接受 `function`,其他类型返回 400 |
|
|
||||||
| `tool_choice` | ◐ | 支持 `auto` / `none` / `required` 和具名 function;非法或未知值被静默忽略 |
|
|
||||||
| `response_format` | ◐ | `json_object`、`json_schema` 转为 Responses `text.format`;未知类型交给 OCI 处理 |
|
|
||||||
| `reasoning_effort` | ◐ | 转小写后映射为 `reasoning.effort`,不校验模型或档位 |
|
|
||||||
| `store` | ⚠️ / 🔄 | 客户端字段被忽略,转换后的上游请求始终使用 `store:false` |
|
|
||||||
| `stop`、`seed`、`n`、`frequency_penalty`、`presence_penalty` | ⚠️ | 静默忽略;`n` 因此恒为单个 choice |
|
|
||||||
| `logprobs`、`top_logprobs`、`logit_bias`、`user` | ⚠️ | 静默忽略 |
|
|
||||||
| `audio`、`modalities`、`prediction`、`metadata`、`moderation` | ⚠️ | 静默忽略 |
|
|
||||||
| `prompt_cache_key`、`safety_identifier`、`service_tier`、`verbosity`、`web_search_options` | ⚠️ | 静默忽略 |
|
|
||||||
| 其他未知字段 | ⚠️ | JSON 解码器不会拒绝未知字段,但转换后的上游请求不包含它们 |
|
|
||||||
|
|
||||||
响应边界:
|
|
||||||
|
|
||||||
- 非流式固定生成一个 `choices[0]`;文本会合并,函数调用转为 `tool_calls`
|
|
||||||
- `finish_reason` 只生成 `stop`、`tool_calls`、`length`;其他上游终止原因不保留
|
|
||||||
- reasoning 输出、logprobs、refusal、annotations、audio、service tier 和 system fingerprint 不返回
|
|
||||||
- usage 保留 `prompt_tokens`、`completion_tokens`、`total_tokens` 和 `prompt_tokens_details.cached_tokens`
|
|
||||||
- 已建立的流中断或上游 `response.failed` 不会转换成标准 SSE 错误事件
|
|
||||||
|
|
||||||
实现依据:[`chatresponses.go`](internal/service/chatresponses.go) · [`openai.go`](internal/aiwire/openai.go) · [`aigateway.go`](internal/api/aigateway.go)
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><code>POST /ai/v1/embeddings</code> 对比 OpenAI Embeddings</summary>
|
|
||||||
|
|
||||||
| 标准字段 | 状态 | 网关行为 |
|
|
||||||
| --- | :---: | --- |
|
|
||||||
| `model` | ◐ | 必填,受密钥白名单限制,并且必须存在具有 `EMBEDDING` 能力的渠道 |
|
|
||||||
| `input` 为字符串 | ✅ | 包装为单个输入后调用 OCI |
|
|
||||||
| `input` 为字符串数组 | ✅ | 按原顺序调用 OCI |
|
|
||||||
| `input` 为 token ID 数组或二维 token ID 数组 | ❌ | 只能解码字符串或字符串数组,绑定阶段返回 400 |
|
|
||||||
| 空数组 / `null` | ❌ | 本地返回 400;空字符串不在本地拒绝,由 OCI 决定 |
|
|
||||||
| `dimensions` | ◐ | 映射为 OCI 输出维度,不做范围或模型能力校验 |
|
|
||||||
| `encoding_format=float` | ✅ | 返回 float 数组;省略时行为相同 |
|
|
||||||
| `encoding_format=base64` | ❌ | 本地返回 400,不提供 base64 响应 |
|
|
||||||
| `user` | ⚠️ | 能解析但不会传给 OCI |
|
|
||||||
| 其他未知字段 | ⚠️ | 静默忽略 |
|
|
||||||
|
|
||||||
响应使用标准的 `object:"list"`、`data[].object:"embedding"`、`index`、`model` 和可选 `usage` 外壳;向量为 `float32` 数组,不支持流式。
|
|
||||||
|
|
||||||
实现依据:[`embeddings.go`](internal/aiwire/embeddings.go) · [`aigateway_chat.go`](internal/service/aigateway_chat.go) · [`aigateway.go`](internal/api/aigateway.go)
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><code>POST /ai/v1/messages</code> 对比 Anthropic Messages</summary>
|
|
||||||
|
|
||||||
网关接受 `Authorization: Bearer` 或 `x-api-key`,但不会校验或使用标准 Anthropic `anthropic-version`、`anthropic-beta` 请求头。
|
|
||||||
|
|
||||||
| 标准字段 | 状态 | 网关行为 |
|
|
||||||
| --- | :---: | --- |
|
|
||||||
| `model` | ◐ | 用于模型与渠道选择,但空字符串不会在 handler 中按参数错误拒绝,通常最终返回模型不存在 |
|
|
||||||
| `max_tokens` | 🔄 | 必填且必须大于 0,转换为 `max_output_tokens` |
|
|
||||||
| `messages` | ◐ | 必须非空;角色、交替顺序和空内容不做完整校验 |
|
|
||||||
| `system` | ◐ | 支持字符串或 text 块数组;多个文本块直接拼接,`cache_control` 等附加字段被忽略 |
|
|
||||||
| `temperature`、`top_p` | ◐ | 写入 Responses 请求,不做取值范围或模型能力校验 |
|
|
||||||
| `top_k` | ⚠️ | 能解析但不会传给上游 |
|
|
||||||
| `stop_sequences` | ⚠️ | 能解析但不会传给上游;响应 `stop_sequence` 恒为 `null` |
|
|
||||||
| `stream` | 🔄 | Responses SSE 桥接为 Anthropic 事件序列 |
|
|
||||||
| `tools` | ◐ | 每个工具都转换成 Responses `function`;自定义客户端工具可用,Anthropic 服务端工具类型不保留原语义 |
|
|
||||||
| `tool_choice` | ◐ | 支持 `auto`、`any`、`none`、具名 `tool`;`disable_parallel_tool_use` 等附加字段被忽略 |
|
|
||||||
| `metadata` | ⚠️ | 能解析但不会传给上游 |
|
|
||||||
| `thinking` | ⚠️ | 顶层 thinking 配置不会控制上游思考预算 |
|
|
||||||
| `output_config.effort` | 🔄 | 转小写后映射为 Responses `reasoning.effort` |
|
|
||||||
| `output_config` 其他子字段 | ⚠️ | 未建模,静默忽略 |
|
|
||||||
| `cache_control`、`container`、`inference_geo`、`service_tier` | ⚠️ | 标准 SDK 中存在,但当前请求结构未建模,静默忽略 |
|
|
||||||
| 其他未知顶层字段 | ⚠️ | JSON 解码器接受,但转换后的上游请求不包含它们 |
|
|
||||||
|
|
||||||
`messages[].content`:
|
|
||||||
|
|
||||||
| 标准内容块 | 状态 | 网关行为 |
|
|
||||||
| --- | :---: | --- |
|
|
||||||
| 字符串 / `text` | 🔄 | user 转 `input_text`,assistant 历史转 `output_text` |
|
|
||||||
| `image` | ◐ | 仅支持 `base64` 和 `url` source;缺字段或其他 source 类型返回 400 |
|
|
||||||
| `tool_use` | 🔄 | 转为 `function_call`,保留 ID、名称和输入 |
|
|
||||||
| `tool_result` | ◐ | 转为 `function_call_output`;块数组只拼接 text,`is_error` 和非文本结果丢失 |
|
|
||||||
| `thinking` / `redacted_thinking` | ⚠️ | 历史思考块被删除,不进入上游上下文 |
|
|
||||||
| `document`、服务端工具结果及其他未知块 | ❌ | 返回 400 |
|
|
||||||
| `null` / 空块数组 | ◐ | 该消息可能从上游 input 中消失,不返回参数错误 |
|
|
||||||
|
|
||||||
响应边界:
|
|
||||||
|
|
||||||
- 非流式只把 Responses `output_text` 转成 `text`、`function_call` 转成 `tool_use`
|
|
||||||
- `stop_reason` 只生成 `end_turn`、`tool_use`、`max_tokens`;`stop_sequence` 恒为 `null`
|
|
||||||
- reasoning 不会生成 Anthropic `thinking` / `redacted_thinking` 块,也没有 signature
|
|
||||||
- usage 只保留 `input_tokens`、`output_tokens` 和 `cache_read_input_tokens`,不提供 `cache_creation_input_tokens`
|
|
||||||
- 流式输出标准事件骨架,但不生成 `thinking_delta`、`signature_delta` 或上游失败对应的 Anthropic `error` 事件
|
|
||||||
|
|
||||||
实现依据:[`anthresponses.go`](internal/service/anthresponses.go) · [`anthropic.go`](internal/aiwire/anthropic.go) · [`aigateway.go`](internal/api/aigateway.go)
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
`GET /ai/v1/models` 使用 OpenAI Models 列表外壳(`object`、`data[].id/object/created/owned_by`),但只返回当前渠道目录中通过分组、全局黑名单和密钥白名单筛选后的模型;网关不提供标准的单模型检索端点。
|
|
||||||
|
|
||||||
## API 与配置
|
## API 与配置
|
||||||
|
|
||||||
@@ -436,7 +261,7 @@ go vet ./...
|
|||||||
go test ./...
|
go test ./...
|
||||||
|
|
||||||
# Handler 注释变更后重新生成唯一的对外 API 文档。
|
# Handler 注释变更后重新生成唯一的对外 API 文档。
|
||||||
go tool swag init -g cmd/server/main.go -o docs --parseInternal --parseDependency
|
go tool swag init -g cmd/server/main.go -o docs --parseInternal --parseDependency --overridesFile docs/.swaggo
|
||||||
```
|
```
|
||||||
|
|
||||||
- Go 后端规范与目录约定:[`AGENTS.md`](AGENTS.md) · [`.trellis/spec/backend/`](.trellis/spec/backend/)
|
- Go 后端规范与目录约定:[`AGENTS.md`](AGENTS.md) · [`.trellis/spec/backend/`](.trellis/spec/backend/)
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// swag 全局类型替换:原始 JSON 与联合类型统一渲染为 AnyJSON(任意 JSON 值),
|
||||||
|
// 避免 json.RawMessage 被误渲染成 byte 数组、联合类型暴露内部字段。
|
||||||
|
replace encoding/json.RawMessage oci-portal/internal/aiwire.AnyJSON
|
||||||
|
replace oci-portal/internal/aiwire.RespInput oci-portal/internal/aiwire.AnyJSON
|
||||||
|
replace oci-portal/internal/aiwire.Content oci-portal/internal/aiwire.AnyJSON
|
||||||
|
replace oci-portal/internal/aiwire.StringList oci-portal/internal/aiwire.AnyJSON
|
||||||
+294
@@ -0,0 +1,294 @@
|
|||||||
|
# AI 网关
|
||||||
|
|
||||||
|
> 本文是 OCI Portal AI 网关的完整使用与兼容性文档:端点定位、协议兼容边界、已知上游限制与字段兼容矩阵。
|
||||||
|
> 路由与鉴权的机器可读定义以 [Swagger YAML](swagger.yaml) 或运行时 Swagger UI 为准;无法由 OpenAPI 表达的兼容边界以本文为准。
|
||||||
|
|
||||||
|
AI 网关使用面板创建的独立密钥鉴权,支持 `Authorization: Bearer sk-...` 和 `x-api-key: sk-...`。密钥可绑定渠道分组和模型白名单;全局模型黑名单会从模型列表、路由和探测候选中同时排除目标模型。
|
||||||
|
|
||||||
|
| 端点 | 定位 | 流式 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `POST /ai/v1/responses` | OpenAI Responses,无状态主接口 | SSE |
|
||||||
|
| `POST /ai/v1/chat/completions` | OpenAI Chat Completions,存量客户端兼容层 | SSE |
|
||||||
|
| `POST /ai/v1/messages` | Anthropic Messages 转换层 | SSE |
|
||||||
|
| `POST /ai/v1/embeddings` | OpenAI Embeddings | 否 |
|
||||||
|
| `POST /ai/v1/audio/speech` | OpenAI Audio Speech,文本转语音(xAI Voice) | 否 |
|
||||||
|
| `POST /ai/v1/tts` | xAI 官方格式文本转语音(同一上游,网关转换) | 否 |
|
||||||
|
| `POST /ai/v1/rerank` | 文档重排(Cohere Rerank,Jina 风格协议) | 否 |
|
||||||
|
| `POST /ai/v1/moderations` | 内容审核(OCI Guardrails:内容审核 / PII / 提示注入) | 否 |
|
||||||
|
| `GET /ai/v1/models` | 当前密钥可见的模型列表 | 否 |
|
||||||
|
|
||||||
|
兼容边界:
|
||||||
|
|
||||||
|
- 对话请求统一转发 OCI OpenAI 兼容面,当前供给以 `xai.`、`meta.`、`openai.` 前缀模型为主;Cohere Embeddings 不受该对话模型范围影响
|
||||||
|
- 网关不保存会话历史,客户端需要携带完整上下文;Responses 拒绝非空 `previous_response_id`、非 `null` `conversation` 和 `background:true`
|
||||||
|
- Responses 支持 Oracle 文档化的 xAI 服务端工具 `web_search` / `x_search` / `code_interpreter` 与远程 `mcp` 工具(非流式与流式均可),工具参数与限制遵循 [xAI 规格](https://docs.oracle.com/en-us/iaas/Content/generative-ai/get-started-agents.htm#xai-compatible-tools);`code_interpreter` 的命名容器管理(containers API)与 File Search 不提供
|
||||||
|
- Responses 的 `reasoning.effort`、Messages 的 `output_config.effort` 和 Chat Completions 的 `reasoning_effort` 会传给上游,实际档位和效果由模型决定
|
||||||
|
- Chat Completions 只承担协议转换与兼容修复;新能力优先在 Responses 和 Messages 提供
|
||||||
|
- 单次请求最多尝试三个渠道;可重试错误会切换渠道,流式响应建立后不会换渠道重试
|
||||||
|
- Audio Speech 直通 OCI 兼容面(模型 `xai.grok-tts`,voice 取 xAI Grok Voice 列表:`ara`/`eve`/`leo`/`rex`/`sal`);上游把 `language` 当必填,缺省时网关自动注入 `"auto"`,xAI 专属参数(`output_format` 等)可平铺在请求体透传;仅单请求返回音频,不提供 WebSocket 流式
|
||||||
|
- `/ai/v1/tts` 为 xAI 官方 TTS 格式(`text`/`language` 必填、`voice_id`、`output_format` 对象)的转换端点:网关转换为 OpenAI 兼容形态后走同一上游与渠道调度,`model` 为网关扩展字段(缺省 `xai.grok-tts`);实测 `output_format`(codec/sample_rate/bit_rate)与 `speed` 透传生效
|
||||||
|
- Rerank 走 OCI typed 面(`cohere.rerank-v4.0-pro` / `-fast`),请求 `{model, query, documents[], top_n?, return_documents?}`,响应 `results[].index` 指向入参下标;无 token 用量口径,调用日志只记时延
|
||||||
|
- Moderations 是 OpenAI moderations 外壳映射 OCI Guardrails:`input` 为字符串或字符串数组(至多 8 条),categories 用 OCI 原生维度 `overall` / `blocklist` / `prompt_injection`(阈值 0.5 判定 `flagged`),PII 命中放扩展字段 `results[].pii`(不参与 flagged);`model` 字段接受但忽略,无模型白名单维度;实测中文人名/手机号识别较弱,英文 PII 识别正常
|
||||||
|
- Responses 的 `input_file` 内容块(file_url / file_data)实测被上游拒绝:`File content is currently unsupported for ZDR customers`——网关强制 `store:false` 属 ZDR 形态,该能力在上游侧不可用
|
||||||
|
|
||||||
|
### 已知上游限制:大 system 区流式断流
|
||||||
|
|
||||||
|
实测(2026-07-13)OCI 兼容面对 `instructions` 与 `tools` 合计超约 64.5KB 的**流式**请求会在发出少量事件后静默断开连接(无任何错误事件;同请求非流式正常),与模型、字符集、消息正文大小均无关——消息正文(`input`)不计入该限制。Chat Completions 与 Messages 的 system/developer 提示会转换为 `instructions`,因此 Claude Code 等自带大体量系统提示与工具定义的客户端极易触发。
|
||||||
|
|
||||||
|
网关侧应对:
|
||||||
|
|
||||||
|
- Messages 与 Chat Completions 的流式请求在客户端尚未收到任何输出时遭遇上游断流,会自动降级为非流式重做,并按标准事件/chunk 序列一次推送;调用日志记 `retries=1` 与降级标记
|
||||||
|
- Responses 直通因初始事件已转发、协议上无法透明降级,调用日志记「上游流提前终止」,客户端需自行回退非流式
|
||||||
|
- 应急规避:将超长 system 内容移入首条 user 消息正文可绕过该限制(正文不计入),但语义有别,根治有待上游修复
|
||||||
|
|
||||||
|
这里提供的是兼容接口而非 OpenAI / Anthropic 协议的完整实现。OCI OpenAI 兼容面的部分行为来自实测,未见 Oracle 文档承诺,可能随上游调整。路由与鉴权定义以 [Swagger YAML](swagger.yaml) 或运行时 Swagger UI 为准;无法由 OpenAPI 完整表达的兼容边界列于上方。
|
||||||
|
|
||||||
|
### 字段兼容矩阵
|
||||||
|
|
||||||
|
以下矩阵以 2026-07-13 的 [OpenAI Responses](https://developers.openai.com/api/reference/resources/responses/methods/create)、[Chat Completions](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create)、[Embeddings](https://developers.openai.com/api/reference/resources/embeddings/methods/create)、[Audio Speech](https://developers.openai.com/api/reference/resources/audio/methods/speech)、[Moderations](https://developers.openai.com/api/reference/resources/moderations/methods/create) 和 [Anthropic Messages](https://platform.claude.com/docs/en/api/messages/create) 为标准基线,并与当前实现逐项核对;Rerank 无 OpenAI 对应端点,基线取 [Cohere Rerank](https://docs.cohere.com/reference/rerank) 协议。
|
||||||
|
|
||||||
|
这是一份兼容性快照,不替代 Swagger。标准接口和 OCI 上游都可能变化,最终行为以当前版本代码与实测为准。
|
||||||
|
|
||||||
|
矩阵只列网关支持的字段;不支持(本地拒绝)与被忽略的字段不入表,在各端点段落末尾以文字简述。
|
||||||
|
|
||||||
|
| 标记 | 含义 |
|
||||||
|
| :---: | --- |
|
||||||
|
| ✅ | 网关直接支持 |
|
||||||
|
| ➡️ | 网关原样透传;是否生效由 OCI 上游决定 |
|
||||||
|
| 🔄 | 网关进行字段或协议转换后支持 |
|
||||||
|
| ◐ | 部分支持、存在前置条件或语义降级 |
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><code>POST /ai/v1/responses</code> 对比 OpenAI Responses</summary>
|
||||||
|
|
||||||
|
Responses 是“原始 JSON 直通 + 本地门禁”。除 `store` 外,网关不会重建请求体;未知顶层字段也会保留并送往 OCI。
|
||||||
|
|
||||||
|
| 标准字段 | 状态 | 网关行为 |
|
||||||
|
| --- | :---: | --- |
|
||||||
|
| `model` | ◐ | 必须非空,还要通过密钥模型白名单并匹配可用渠道;随后原样透传 |
|
||||||
|
| `input` | ➡️ | 支持标准的字符串或 item 数组,原始内容透传;网关不逐项保证 OCI 能处理所有 item 类型 |
|
||||||
|
| `instructions`、`max_output_tokens` | ➡️ | 类型可解析后原样透传,不做范围或模型能力校验 |
|
||||||
|
| `temperature`、`top_p`、`parallel_tool_calls` | ➡️ | 原样透传,不做取值范围校验 |
|
||||||
|
| `text` / `text.format` / `text.verbosity` | ➡️ | 整个原始对象透传;结构化输出是否可用由 OCI 模型决定 |
|
||||||
|
| `reasoning` | ➡️ | 整个原始对象透传;`effort` 不校验档位,`summary` 等字段不会被网关删除 |
|
||||||
|
| `tool_choice` | ➡️ | 任意 JSON 原样透传,本地不校验枚举或结构 |
|
||||||
|
| `store` | 🔄 | 无论客户端传什么,上游请求都强制改写为 `false` |
|
||||||
|
| `stream` | ✅ | 普通请求、`function` 与服务端工具均支持 SSE |
|
||||||
|
| `tools[].type=function` | ➡️ | 非流式和流式均可,工具对象原样透传 |
|
||||||
|
| `tools[].type=web_search` / `x_search` / `code_interpreter` | ◐ | Oracle 文档化的 xAI 服务端工具,参数与限制遵循 xAI 规格(如 `allowed_domains` 上限 10、`container` 支持 `{"type":"auto"}`),非流式与流式均实测可用;`x_search` 不是 OpenAI 标准工具 |
|
||||||
|
| `tools[].type=mcp` | ➡️ | 远程 MCP 服务由上游直连调用(`server_url` / `require_approval` / `authorization` 等原样透传),非流式与流式均实测可用 |
|
||||||
|
| 其余标准与未知顶层字段 | ➡️ | `context_management`、`include`、`metadata`、`prompt`、`prompt_cache_key`、`service_tier`、`truncation`、`user` 等未建模字段一律保留在原始请求中,由 OCI 决定是否接受 |
|
||||||
|
|
||||||
|
不支持(请求到达上游前返回 400):非空 `previous_response_id`、非 `null` 的 `conversation`、`background:true`——网关无状态,不保存历史响应;以及 `function` / xAI 服务端工具 / `mcp` 之外的工具类型(`web_search_preview`、`file_search`、`computer`、`image_generation`、`shell`、`custom` 等)。
|
||||||
|
|
||||||
|
响应边界:
|
||||||
|
|
||||||
|
- 非流式成功响应不做转换,OCI JSON 原样返回;usage 解析只用于面板调用日志
|
||||||
|
- SSE 事件逐行原样转发,不补 `data: [DONE]`,推理增量也不会被网关过滤
|
||||||
|
- 流建立前最多切换三个渠道;流建立后中断不重试,客户端可能只收到部分事件
|
||||||
|
- 未知模型返回 404、无渠道返回 503;上游错误会套入 OpenAI 风格错误体,不保证与标准 OpenAI 错误字段完全相同
|
||||||
|
|
||||||
|
实现依据:[`airesponses.go`](../internal/service/airesponses.go) · [`responses.go`](../internal/aiwire/responses.go) · [`aigateway.go`](../internal/api/aigateway.go)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><code>POST /ai/v1/chat/completions</code> 对比 OpenAI Chat Completions</summary>
|
||||||
|
|
||||||
|
Chat Completions 会先转换为 Responses 请求,再把 OCI Responses 响应桥接回 Chat Completions 形态。转换器只搬运下表字段,其余字段不进入上游请求。
|
||||||
|
|
||||||
|
| 标准字段 | 状态 | 网关行为 |
|
||||||
|
| --- | :---: | --- |
|
||||||
|
| `model` | ◐ | 必填,受密钥白名单和可用渠道限制;模型名保留到上游请求 |
|
||||||
|
| `messages` | 🔄 | 必填并转换为 Responses `input` / `instructions` |
|
||||||
|
| `system` / `developer` 消息 | ◐ | 文本按出现顺序合并为 `instructions`;块数组中的非文本内容被忽略 |
|
||||||
|
| `user` / `assistant` 文本内容 | 🔄 | 字符串及 `text` 块分别转为 `input_text` / `output_text` |
|
||||||
|
| `image_url` 内容块 | ◐ | URL 或 data URI 转为 `input_image`;`image_url.detail` 被忽略 |
|
||||||
|
| assistant `tool_calls` / `role=tool` | 🔄 | 转为 `function_call` / `function_call_output`,保留调用 ID、函数名和参数 |
|
||||||
|
| `max_completion_tokens` | 🔄 | 转为 `max_output_tokens`,优先于 `max_tokens` |
|
||||||
|
| `max_tokens` | 🔄 | 未提供 `max_completion_tokens` 时转为 `max_output_tokens` |
|
||||||
|
| `temperature`、`top_p`、`parallel_tool_calls` | ◐ | 原值写入 Responses 请求,但不校验范围或模型能力 |
|
||||||
|
| `stream` | 🔄 | OCI Responses SSE 桥接为 `chat.completion.chunk`,末尾补 `data: [DONE]`;上游断流且尚无输出时自动降级非流式重做,结果按 chunk 序列一次推送 |
|
||||||
|
| `stream_options.include_usage` | 🔄 | 控制网关在终块后追加 `choices: []` 的 usage 块 |
|
||||||
|
| `tools[].type=function` | ◐ | `name`、`description`、`parameters` 支持;`function.strict` 被忽略 |
|
||||||
|
| `tool_choice` | ◐ | 支持 `auto` / `none` / `required` 和具名 function;非法或未知值被静默忽略 |
|
||||||
|
| `response_format` | ◐ | `json_object`、`json_schema` 转为 Responses `text.format`;未知类型交给 OCI 处理 |
|
||||||
|
| `reasoning_effort` | ◐ | 转小写后映射为 `reasoning.effort`,不校验模型或档位 |
|
||||||
|
| `store` | 🔄 | 客户端取值被忽略,转换后的上游请求始终使用 `store:false` |
|
||||||
|
|
||||||
|
不支持(返回 400):`input_audio`、`file`、`refusal` 等消息内容块;`function` 以外的工具类型(含 `custom`)。
|
||||||
|
|
||||||
|
静默忽略(转换后的上游请求不包含):消息级 `name` / `refusal` / `audio` / 旧式 `function_call`;采样与输出控制类 `stop`、`seed`、`n`(恒返回单个 choice)、`frequency_penalty`、`presence_penalty`、`logprobs`、`top_logprobs`、`logit_bias`;平台类 `user`、`audio`、`modalities`、`prediction`、`metadata`、`moderation`、`prompt_cache_key`、`safety_identifier`、`service_tier`、`verbosity`、`web_search_options`、`stream_options.include_obfuscation` 及其他未知字段。
|
||||||
|
|
||||||
|
响应边界:
|
||||||
|
|
||||||
|
- 非流式固定生成一个 `choices[0]`;文本会合并,函数调用转为 `tool_calls`
|
||||||
|
- `finish_reason` 只生成 `stop`、`tool_calls`、`length`;其他上游终止原因不保留
|
||||||
|
- reasoning 输出、logprobs、refusal、annotations、audio、service tier 和 system fingerprint 不返回
|
||||||
|
- usage 保留 `prompt_tokens`、`completion_tokens`、`total_tokens` 和 `prompt_tokens_details.cached_tokens`
|
||||||
|
- 已建立的流中断或上游 `response.failed` 不会转换成标准 SSE 错误事件
|
||||||
|
|
||||||
|
实现依据:[`chatresponses.go`](../internal/service/chatresponses.go) · [`openai.go`](../internal/aiwire/openai.go) · [`aigateway.go`](../internal/api/aigateway.go)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><code>POST /ai/v1/embeddings</code> 对比 OpenAI Embeddings</summary>
|
||||||
|
|
||||||
|
| 标准字段 | 状态 | 网关行为 |
|
||||||
|
| --- | :---: | --- |
|
||||||
|
| `model` | ◐ | 必填,受密钥白名单限制,并且必须存在具有 `EMBEDDING` 能力的渠道 |
|
||||||
|
| `input` 为字符串 | ✅ | 包装为单个输入后调用 OCI |
|
||||||
|
| `input` 为字符串数组 | ✅ | 按原顺序调用 OCI;空字符串不在本地拒绝,由 OCI 决定 |
|
||||||
|
| `dimensions` | ◐ | 映射为 OCI 输出维度,不做范围或模型能力校验 |
|
||||||
|
| `encoding_format=float` | ✅ | 返回 float 数组;省略时行为相同 |
|
||||||
|
|
||||||
|
不支持(返回 400):token ID 数组(一维或二维)形态的 `input`、空数组、`null`,以及 `encoding_format=base64`。静默忽略:`user` 及其他未知字段。
|
||||||
|
|
||||||
|
响应使用标准的 `object:"list"`、`data[].object:"embedding"`、`index`、`model` 和可选 `usage` 外壳;向量为 `float32` 数组,不支持流式。
|
||||||
|
|
||||||
|
实现依据:[`embeddings.go`](../internal/aiwire/embeddings.go) · [`aigateway_chat.go`](../internal/service/aigateway_chat.go) · [`aigateway.go`](../internal/api/aigateway.go)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><code>POST /ai/v1/messages</code> 对比 Anthropic Messages</summary>
|
||||||
|
|
||||||
|
网关接受 `Authorization: Bearer` 或 `x-api-key`,但不会校验或使用标准 Anthropic `anthropic-version`、`anthropic-beta` 请求头。
|
||||||
|
|
||||||
|
| 标准字段 | 状态 | 网关行为 |
|
||||||
|
| --- | :---: | --- |
|
||||||
|
| `model` | ◐ | 用于模型与渠道选择,但空字符串不会在 handler 中按参数错误拒绝,通常最终返回模型不存在 |
|
||||||
|
| `max_tokens` | 🔄 | 可缺省(缺省或 ≤0 时按默认值 8192),转换为 `max_output_tokens` |
|
||||||
|
| `messages` | ◐ | 必须非空;角色、交替顺序和空内容不做完整校验 |
|
||||||
|
| `system` | ◐ | 支持字符串或 text 块数组;多个文本块直接拼接,`cache_control` 等附加字段被忽略 |
|
||||||
|
| `temperature`、`top_p` | ◐ | 写入 Responses 请求,不做取值范围或模型能力校验 |
|
||||||
|
| `stream` | 🔄 | Responses SSE 桥接为 Anthropic 事件序列;上游断流且尚无输出时自动降级非流式重做,结果按事件序列一次推送 |
|
||||||
|
| `tools` | ◐ | 每个工具都转换成 Responses `function`;自定义客户端工具可用,Anthropic 服务端工具类型不保留原语义 |
|
||||||
|
| `tool_choice` | ◐ | 支持 `auto`、`any`、`none`、具名 `tool`;`disable_parallel_tool_use` 等附加字段被忽略 |
|
||||||
|
| `output_config.effort` | 🔄 | 转小写后映射为 Responses `reasoning.effort` |
|
||||||
|
|
||||||
|
顶层静默忽略(能解析但不传上游):`top_k`、`stop_sequences`(响应 `stop_sequence` 恒为 `null`)、`metadata`、`thinking`(不控制上游思考预算)、`output_config` 其余子字段、`cache_control`、`container`、`inference_geo`、`service_tier` 及其他未知顶层字段。
|
||||||
|
|
||||||
|
`messages[].content`:
|
||||||
|
|
||||||
|
| 标准内容块 | 状态 | 网关行为 |
|
||||||
|
| --- | :---: | --- |
|
||||||
|
| 字符串 / `text` | 🔄 | user 转 `input_text`,assistant 历史转 `output_text` |
|
||||||
|
| `image` | ◐ | 仅支持 `base64` 和 `url` source;缺字段或其他 source 类型返回 400 |
|
||||||
|
| `tool_use` | 🔄 | 转为 `function_call`,保留 ID、名称和输入 |
|
||||||
|
| `tool_result` | ◐ | 转为 `function_call_output`;块数组只拼接 text,`is_error` 和非文本结果丢失 |
|
||||||
|
| `null` / 空块数组 | ◐ | 该消息可能从上游 input 中消失,不返回参数错误 |
|
||||||
|
|
||||||
|
不支持的内容块(返回 400):`document`、服务端工具结果及其他未知块。历史 `thinking` / `redacted_thinking` 块会被删除,不进入上游上下文。
|
||||||
|
|
||||||
|
响应边界:
|
||||||
|
|
||||||
|
- 非流式只把 Responses `output_text` 转成 `text`、`function_call` 转成 `tool_use`
|
||||||
|
- `stop_reason` 只生成 `end_turn`、`tool_use`、`max_tokens`;`stop_sequence` 恒为 `null`
|
||||||
|
- reasoning 不会生成 Anthropic `thinking` / `redacted_thinking` 块,也没有 signature
|
||||||
|
- usage 只保留 `input_tokens`、`output_tokens` 和 `cache_read_input_tokens`,不提供 `cache_creation_input_tokens`
|
||||||
|
- 流式输出标准事件骨架,但不生成 `thinking_delta` 和 `signature_delta`;上游错误事件与无终态断流会转成 Anthropic `error` 事件
|
||||||
|
|
||||||
|
实现依据:[`anthresponses.go`](../internal/service/anthresponses.go) · [`anthropic.go`](../internal/aiwire/anthropic.go) · [`aigateway.go`](../internal/api/aigateway.go)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><code>POST /ai/v1/audio/speech</code> 对比 OpenAI Audio Speech</summary>
|
||||||
|
|
||||||
|
Audio Speech 与 Responses 同为“原始 JSON 直通 + 本地门禁”:除缺省注入 `language` 外不重建请求体,未知字段原样透传。
|
||||||
|
|
||||||
|
| 标准字段 | 状态 | 网关行为 |
|
||||||
|
| --- | :---: | --- |
|
||||||
|
| `model` | ◐ | 必填,受密钥白名单限制,并且必须存在具有 `TTS` 能力的渠道(当前上游仅 `xai.grok-tts`) |
|
||||||
|
| `input` | ◐ | 必填非空,随后原样透传 |
|
||||||
|
| `voice` | ➡️ | 原样透传;取 xAI Grok Voice 音色(`ara` / `eve` / `leo` / `rex` / `sal`),OpenAI 标准音色名不可用 |
|
||||||
|
| `response_format` | ➡️ | 原样透传;实测 `mp3` 可用,其余格式由上游决定 |
|
||||||
|
| `language`(扩展字段) | 🔄 | 上游必填;客户端缺省时网关自动注入 `"auto"` |
|
||||||
|
| `speed`、`instructions` 及其他未知字段 | ➡️ | 原样透传(含 xAI 专属参数如 `output_format`),是否生效由上游决定 |
|
||||||
|
|
||||||
|
不支持:流式音频(`stream_format` 等流式选项无效,响应恒为一次性完整音频)与 WebSocket 语音会话。
|
||||||
|
|
||||||
|
响应边界:
|
||||||
|
|
||||||
|
- 成功响应为音频字节,`Content-Type` 透传上游(缺省 `audio/mpeg`)
|
||||||
|
- 无 token 用量口径,调用日志只记时延与渠道
|
||||||
|
|
||||||
|
实现依据:[`genai_speech.go`](../internal/oci/genai_speech.go) · [`service/aigateway_extras.go`](../internal/service/aigateway_extras.go) · [`api/aigateway_extras.go`](../internal/api/aigateway_extras.go)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><code>POST /ai/v1/tts</code> 对比 xAI TTS</summary>
|
||||||
|
|
||||||
|
`/ai/v1/tts` 接受 [xAI 官方 TTS 格式](https://docs.x.ai/developers/model-capabilities/audio/text-to-speech)(OCI 无 HTTP 版 xAI 面,网关转换为 OpenAI 兼容形态后与 `/ai/v1/audio/speech` 走同一上游与调度)。
|
||||||
|
|
||||||
|
| 标准字段 | 状态 | 网关行为 |
|
||||||
|
| --- | :---: | --- |
|
||||||
|
| `text` | 🔄 | 必填非空,转换为上游 `input` |
|
||||||
|
| `language` | ◐ | 必填(对齐 xAI 官方;接受 BCP-47 或 `auto`),原样透传 |
|
||||||
|
| `voice_id` | 🔄 | 转换为上游 `voice`;缺省交上游默认(`eve`) |
|
||||||
|
| `output_format` | ➡️ | `{codec, sample_rate, bit_rate}` 对象原样透传,实测生效(44.1kHz/192kbps 验证) |
|
||||||
|
| `speed` | ➡️ | 原样透传,实测接受 |
|
||||||
|
| `model`(网关扩展) | ◐ | xAI 官方无此字段;缺省注入 `xai.grok-tts`,可显式覆盖,受密钥白名单限制 |
|
||||||
|
| `optimize_streaming_latency`、`text_normalization`、`with_timestamps` 及其他未知字段 | ➡️ | 原样透传,是否生效由上游决定 |
|
||||||
|
|
||||||
|
不支持:流式音频输出(xAI 官方 `optimize_streaming_latency` 面向流式场景,本端点响应恒为一次性完整音频);WebSocket 流式(OCI 另有 `wss://…/xai/v1/tts` 私有协议面,网关未代理)。
|
||||||
|
|
||||||
|
响应边界:
|
||||||
|
|
||||||
|
- 成功响应为音频字节,`Content-Type` 透传上游(缺省 `audio/mpeg`)
|
||||||
|
- 无 token 用量口径,调用日志只记时延与渠道(端点名 `tts`)
|
||||||
|
|
||||||
|
实现依据:[`service/aigateway_extras.go`](../internal/service/aigateway_extras.go) · [`api/aigateway_extras.go`](../internal/api/aigateway_extras.go)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><code>POST /ai/v1/rerank</code> 对比 Cohere Rerank</summary>
|
||||||
|
|
||||||
|
Rerank 无 OpenAI 对应端点,协议取 Jina / Cohere 通行风格,上游走 OCI typed 面(`cohere.rerank-v4.0-pro` / `-fast`)。
|
||||||
|
|
||||||
|
| 字段 | 状态 | 网关行为 |
|
||||||
|
| --- | :---: | --- |
|
||||||
|
| `model` | ◐ | 必填,受密钥白名单限制,并且必须存在具有 `RERANK` 能力的渠道 |
|
||||||
|
| `query` | ✅ | 必填非空 |
|
||||||
|
| `documents` | ◐ | 必填,仅接受字符串数组;Cohere 旧版 `{"text": ...}` 对象数组形态返回 400 |
|
||||||
|
| `top_n` | ✅ | 可选,传给上游限制返回条数;缺省返回全部文档的重排结果 |
|
||||||
|
| `return_documents` | ✅ | 可选,`true` 时 `results[].document.text` 回带原文 |
|
||||||
|
|
||||||
|
静默忽略:`max_tokens_per_doc` 等 Cohere 专属参数及其他未知字段。
|
||||||
|
|
||||||
|
响应边界:
|
||||||
|
|
||||||
|
- `results[]` 按相关度降序,`index` 指向入参 `documents` 下标,`relevance_score` 为 0–1 浮点;响应 `model` 回显请求值
|
||||||
|
- 无 token 用量口径,调用日志只记时延与渠道
|
||||||
|
|
||||||
|
实现依据:[`genai_guard.go`](../internal/oci/genai_guard.go) · [`rerank.go`](../internal/aiwire/rerank.go) · [`service/aigateway_extras.go`](../internal/service/aigateway_extras.go)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><code>POST /ai/v1/moderations</code> 对比 OpenAI Moderations</summary>
|
||||||
|
|
||||||
|
Moderations 是 OpenAI moderations 外壳映射 OCI Guardrails(内容审核 / PII / 提示注入)。上游是服务级 API,没有模型与白名单维度;渠道按分组直接挑选。
|
||||||
|
|
||||||
|
| 标准字段 | 状态 | 网关行为 |
|
||||||
|
| --- | :---: | --- |
|
||||||
|
| `input` 为字符串 | ✅ | 单条审核 |
|
||||||
|
| `input` 为字符串数组 | ✅ | 逐条审核,单次 1~8 条 |
|
||||||
|
|
||||||
|
不支持(返回 400):多模态 input(图片等对象数组形态)、空字符串条目、空数组或超过 8 条的数组。静默忽略:`model`(接受任意值,不校验白名单)及其他未知字段。
|
||||||
|
|
||||||
|
响应边界:
|
||||||
|
|
||||||
|
- `categories` / `category_scores` 用 OCI 原生维度 `overall` / `blocklist` / `prompt_injection`,而非 OpenAI 标准类目(`hate` / `violence` 等);任一维度得分 ≥ 0.5 判定 `flagged`
|
||||||
|
- PII 命中放扩展字段 `results[].pii`(`text` / `label` / `score` / `offset` / `length`),不参与 `flagged` 判定;实测中文人名 / 手机号识别较弱,英文 PII 识别正常
|
||||||
|
- 响应 `model` 恒为 `oci-guardrails`
|
||||||
|
|
||||||
|
实现依据:[`genai_guard.go`](../internal/oci/genai_guard.go) · [`moderations.go`](../internal/aiwire/moderations.go) · [`service/aigateway_extras.go`](../internal/service/aigateway_extras.go)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
`GET /ai/v1/models` 使用 OpenAI Models 列表外壳(`object`、`data[].id/object/created/owned_by`),但只返回当前渠道目录中通过分组、全局黑名单和密钥白名单筛选后的模型;网关不提供标准的单模型检索端点。面板「过滤弃用模型」开关开启时,OCI 已宣布弃用(即使未到退役日)的模型同时从模型列表与路由中排除,关闭后恢复。
|
||||||
+3567
-331
File diff suppressed because it is too large
Load Diff
+3567
-331
File diff suppressed because it is too large
Load Diff
+2399
-319
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
package aiwire
|
||||||
|
|
||||||
|
// AnyJSON 仅供 swagger 文档渲染:经 .swaggo 全局替换,代表任意 JSON 值
|
||||||
|
// (json.RawMessage 与 string/数组联合类型),运行时代码不使用。
|
||||||
|
type AnyJSON struct{}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package aiwire
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// ModerationsRequest 是 /ai/v1/moderations 请求体;input 兼容 OpenAI 的
|
||||||
|
// string 与 []string 两种形态,model 字段接受但忽略(上游为服务级 API)。
|
||||||
|
type ModerationsRequest struct {
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
Input json.RawMessage `json:"input"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModerationPii 是一处 PII 命中(OCI 扩展,OpenAI 协议无对应物)。
|
||||||
|
type ModerationPii struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
Length int `json:"length"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModerationResult 是一条输入的审核结果:categories / category_scores 用
|
||||||
|
// OCI 原生维度(overall / blocklist / prompt_injection),pii 为扩展字段。
|
||||||
|
type ModerationResult struct {
|
||||||
|
Flagged bool `json:"flagged"`
|
||||||
|
Categories map[string]bool `json:"categories"`
|
||||||
|
CategoryScores map[string]float64 `json:"category_scores"`
|
||||||
|
Pii []ModerationPii `json:"pii,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModerationsResponse 是 /ai/v1/moderations 响应体(OpenAI moderations 外壳)。
|
||||||
|
type ModerationsResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Results []ModerationResult `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpeechRequest 描述 /ai/v1/audio/speech 请求体的已知字段(文档用途)。
|
||||||
|
// 直通端点实际按原样透传,未列字段与 xAI 专属参数(如 output_format)同样保留。
|
||||||
|
type SpeechRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Input string `json:"input"`
|
||||||
|
Voice string `json:"voice,omitempty"`
|
||||||
|
ResponseFormat string `json:"response_format,omitempty"`
|
||||||
|
Language string `json:"language,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TtsRequest 描述 /ai/v1/tts 请求体的已知字段(xAI 官方 TTS 格式,文档用途)。
|
||||||
|
// model 为网关扩展字段(缺省 xai.grok-tts);未列字段原样透传上游。
|
||||||
|
type TtsRequest struct {
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
VoiceID string `json:"voice_id,omitempty"`
|
||||||
|
OutputFormat *TtsOutputFormat `json:"output_format,omitempty"`
|
||||||
|
Speed float64 `json:"speed,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TtsOutputFormat 是 xAI TTS 输出格式配置(缺省 MP3 24kHz/128kbps)。
|
||||||
|
type TtsOutputFormat struct {
|
||||||
|
Codec string `json:"codec,omitempty"`
|
||||||
|
SampleRate int `json:"sample_rate,omitempty"`
|
||||||
|
BitRate int `json:"bit_rate,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package aiwire
|
||||||
|
|
||||||
|
// RerankRequest 是 /ai/v1/rerank 请求体(Jina / Cohere 通行风格)。
|
||||||
|
type RerankRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Query string `json:"query"`
|
||||||
|
Documents []string `json:"documents"`
|
||||||
|
TopN *int `json:"top_n,omitempty"`
|
||||||
|
ReturnDocuments *bool `json:"return_documents,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RerankDocument 包装原文,仅在 return_documents 时返回。
|
||||||
|
type RerankDocument struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RerankResult 是一条重排结果:index 指向请求 documents 下标。
|
||||||
|
type RerankResult struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
RelevanceScore float64 `json:"relevance_score"`
|
||||||
|
Document *RerankDocument `json:"document,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RerankResponse 是 /ai/v1/rerank 响应体。
|
||||||
|
type RerankResponse struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Results []RerankResult `json:"results"`
|
||||||
|
}
|
||||||
@@ -184,3 +184,16 @@ type RespError struct {
|
|||||||
type RespIncomplete struct {
|
type RespIncomplete struct {
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RespResponse 描述 /ai/v1/responses 非流式响应的常用顶层字段(文档用途)。
|
||||||
|
// 直通端点原样返回上游 JSON,未列字段(reasoning、incomplete_details 等)同样保留。
|
||||||
|
type RespResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Output json.RawMessage `json:"output"`
|
||||||
|
Usage *RespUsage `json:"usage,omitempty"`
|
||||||
|
Error *RespError `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func SetAboutRuntime(dbDriver, dbPath string) {
|
|||||||
//
|
//
|
||||||
// @Summary 返回构建、运行时长与资源占用信息,「设置 · 关于」页展示
|
// @Summary 返回构建、运行时长与资源占用信息,「设置 · 关于」页展示
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} aboutResponse
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/about [get]
|
// @Router /api/v1/about [get]
|
||||||
func about(c *gin.Context) {
|
func about(c *gin.Context) {
|
||||||
|
|||||||
+52
-12
@@ -6,6 +6,9 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
_ "oci-portal/internal/aiwire" // swagger 注解引用
|
||||||
|
_ "oci-portal/internal/model" // swagger 注解引用
|
||||||
|
|
||||||
"oci-portal/internal/service"
|
"oci-portal/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,7 +21,7 @@ type aiAdminHandler struct {
|
|||||||
|
|
||||||
// @Summary ---- 密钥 ----
|
// @Summary ---- 密钥 ----
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} itemsResponse[model.AiKey]
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-keys [get]
|
// @Router /api/v1/ai-keys [get]
|
||||||
func (h *aiAdminHandler) listKeys(c *gin.Context) {
|
func (h *aiAdminHandler) listKeys(c *gin.Context) {
|
||||||
@@ -35,7 +38,7 @@ func (h *aiAdminHandler) listKeys(c *gin.Context) {
|
|||||||
// @Summary 生成密钥
|
// @Summary 生成密钥
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Param body body object true "请求体(见接口说明)"
|
// @Param body body object true "请求体(见接口说明)"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} aiKeyCreateResponse "key 为明文密钥,仅本次返回"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-keys [post]
|
// @Router /api/v1/ai-keys [post]
|
||||||
func (h *aiAdminHandler) createKey(c *gin.Context) {
|
func (h *aiAdminHandler) createKey(c *gin.Context) {
|
||||||
@@ -110,7 +113,7 @@ func (h *aiAdminHandler) deleteKey(c *gin.Context) {
|
|||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param body body object true "请求体(见接口说明)"
|
// @Param body body object true "请求体(见接口说明)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} model.AiKey
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-keys/{id}/content-log [put]
|
// @Router /api/v1/ai-keys/{id}/content-log [put]
|
||||||
func (h *aiAdminHandler) updateKeyContentLog(c *gin.Context) {
|
func (h *aiAdminHandler) updateKeyContentLog(c *gin.Context) {
|
||||||
@@ -137,7 +140,7 @@ func (h *aiAdminHandler) updateKeyContentLog(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// @Summary 分页查询内容日志
|
// @Summary 分页查询内容日志
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} pagedResponse[model.AiCallLog]
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-content-logs [get]
|
// @Router /api/v1/ai-content-logs [get]
|
||||||
func (h *aiAdminHandler) listContentLogs(c *gin.Context) {
|
func (h *aiAdminHandler) listContentLogs(c *gin.Context) {
|
||||||
@@ -157,7 +160,7 @@ func (h *aiAdminHandler) listContentLogs(c *gin.Context) {
|
|||||||
|
|
||||||
// @Summary ---- 渠道 ----
|
// @Summary ---- 渠道 ----
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} itemsResponse[model.AiChannel]
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-channels [get]
|
// @Router /api/v1/ai-channels [get]
|
||||||
func (h *aiAdminHandler) listChannels(c *gin.Context) {
|
func (h *aiAdminHandler) listChannels(c *gin.Context) {
|
||||||
@@ -172,7 +175,7 @@ func (h *aiAdminHandler) listChannels(c *gin.Context) {
|
|||||||
// @Summary 创建 AI 渠道
|
// @Summary 创建 AI 渠道
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Param body body service.ChannelInput true "请求体"
|
// @Param body body service.ChannelInput true "请求体"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} model.AiChannel
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-channels [post]
|
// @Router /api/v1/ai-channels [post]
|
||||||
func (h *aiAdminHandler) createChannel(c *gin.Context) {
|
func (h *aiAdminHandler) createChannel(c *gin.Context) {
|
||||||
@@ -236,7 +239,7 @@ func (h *aiAdminHandler) deleteChannel(c *gin.Context) {
|
|||||||
// @Summary 触发探测:服务可见性 + 模型同步 + 配额试调,返回更新后的渠道
|
// @Summary 触发探测:服务可见性 + 模型同步 + 配额试调,返回更新后的渠道
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} model.AiChannel
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-channels/{id}/probe [post]
|
// @Router /api/v1/ai-channels/{id}/probe [post]
|
||||||
func (h *aiAdminHandler) probeChannel(c *gin.Context) {
|
func (h *aiAdminHandler) probeChannel(c *gin.Context) {
|
||||||
@@ -255,7 +258,7 @@ func (h *aiAdminHandler) probeChannel(c *gin.Context) {
|
|||||||
// @Summary 同步渠道模型缓存
|
// @Summary 同步渠道模型缓存
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} itemsResponse[model.AiModelCache]
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-channels/{id}/sync-models [post]
|
// @Router /api/v1/ai-channels/{id}/sync-models [post]
|
||||||
func (h *aiAdminHandler) syncChannelModels(c *gin.Context) {
|
func (h *aiAdminHandler) syncChannelModels(c *gin.Context) {
|
||||||
@@ -275,7 +278,7 @@ func (h *aiAdminHandler) syncChannelModels(c *gin.Context) {
|
|||||||
|
|
||||||
// @Summary ---- 聚合模型与调用日志 ----
|
// @Summary ---- 聚合模型与调用日志 ----
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} itemsResponse[aiwire.Model]
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-models [get]
|
// @Router /api/v1/ai-models [get]
|
||||||
func (h *aiAdminHandler) gatewayModels(c *gin.Context) {
|
func (h *aiAdminHandler) gatewayModels(c *gin.Context) {
|
||||||
@@ -291,7 +294,7 @@ func (h *aiAdminHandler) gatewayModels(c *gin.Context) {
|
|||||||
|
|
||||||
// @Summary 模型黑名单列表
|
// @Summary 模型黑名单列表
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} itemsResponse[model.AiModelBlacklist]
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-blacklist [get]
|
// @Router /api/v1/ai-blacklist [get]
|
||||||
func (h *aiAdminHandler) listBlacklist(c *gin.Context) {
|
func (h *aiAdminHandler) listBlacklist(c *gin.Context) {
|
||||||
@@ -308,7 +311,7 @@ func (h *aiAdminHandler) listBlacklist(c *gin.Context) {
|
|||||||
// @Summary 添加模型黑名单
|
// @Summary 添加模型黑名单
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Param body body object true "请求体:{name: 模型名}"
|
// @Param body body object true "请求体:{name: 模型名}"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} itemResponse[model.AiModelBlacklist]
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-blacklist [post]
|
// @Router /api/v1/ai-blacklist [post]
|
||||||
func (h *aiAdminHandler) addBlacklist(c *gin.Context) {
|
func (h *aiAdminHandler) addBlacklist(c *gin.Context) {
|
||||||
@@ -347,7 +350,7 @@ func (h *aiAdminHandler) removeBlacklist(c *gin.Context) {
|
|||||||
|
|
||||||
// @Summary AI 调用日志列表
|
// @Summary AI 调用日志列表
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} pagedResponse[model.AiContentLog]
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-logs [get]
|
// @Router /api/v1/ai-logs [get]
|
||||||
func (h *aiAdminHandler) listLogs(c *gin.Context) {
|
func (h *aiAdminHandler) listLogs(c *gin.Context) {
|
||||||
@@ -369,3 +372,40 @@ func aiPathID(c *gin.Context) (uint, bool) {
|
|||||||
}
|
}
|
||||||
return uint(id), true
|
return uint(id), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// aiSettingsResponse 是 AI 网关全局设置(文档与响应共用)。
|
||||||
|
type aiSettingsResponse struct {
|
||||||
|
FilterDeprecated bool `json:"filterDeprecated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// aiSettings 返回 AI 网关全局设置。
|
||||||
|
//
|
||||||
|
// @Summary AI 网关全局设置
|
||||||
|
// @Tags AI 管理
|
||||||
|
// @Success 200 {object} aiSettingsResponse
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/ai-settings [get]
|
||||||
|
func (h *aiAdminHandler) aiSettings(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, aiSettingsResponse{FilterDeprecated: h.gw.FilterDeprecated()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateAiSettings 更新 AI 网关全局设置(当前仅「过滤弃用模型」开关)。
|
||||||
|
//
|
||||||
|
// @Summary 更新 AI 网关全局设置
|
||||||
|
// @Tags AI 管理
|
||||||
|
// @Param body body aiSettingsResponse true "开启后已宣布弃用(即使未退役)的模型从列表与路由中排除"
|
||||||
|
// @Success 200 {object} aiSettingsResponse
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/ai-settings [put]
|
||||||
|
func (h *aiAdminHandler) updateAiSettings(c *gin.Context) {
|
||||||
|
var req aiSettingsResponse
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.gw.SetFilterDeprecated(c.Request.Context(), req.FilterDeprecated); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, aiSettingsResponse{FilterDeprecated: h.gw.FilterDeprecated()})
|
||||||
|
}
|
||||||
|
|||||||
+119
-18
@@ -165,8 +165,8 @@ func fillUsage(entry *model.AiCallLog, u *aiwire.Usage) {
|
|||||||
//
|
//
|
||||||
// @Summary OpenAI 兼容向量嵌入
|
// @Summary OpenAI 兼容向量嵌入
|
||||||
// @Tags AI 网关
|
// @Tags AI 网关
|
||||||
// @Param body body object true "OpenAI embeddings 请求体"
|
// @Param body body aiwire.EmbeddingsRequest true "OpenAI embeddings 请求体"
|
||||||
// @Success 200 {object} map[string]any "OpenAI 兼容响应"
|
// @Success 200 {object} aiwire.EmbeddingsResponse "OpenAI 兼容响应"
|
||||||
// @Router /ai/v1/embeddings [post]
|
// @Router /ai/v1/embeddings [post]
|
||||||
func (h *aiGatewayHandler) embeddings(c *gin.Context) {
|
func (h *aiGatewayHandler) embeddings(c *gin.Context) {
|
||||||
var req aiwire.EmbeddingsRequest
|
var req aiwire.EmbeddingsRequest
|
||||||
@@ -210,12 +210,16 @@ func sseHeaders(c *gin.Context) {
|
|||||||
c.Writer.Flush()
|
c.Writer.Flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// anthDefaultMaxTokens 是 max_tokens 缺省时的默认输出上限:Anthropic 协议
|
||||||
|
// 该字段必填,但部分客户端当选填不传,按默认值放行而非 400 拒绝。
|
||||||
|
const anthDefaultMaxTokens = 8192
|
||||||
|
|
||||||
// messages 是 Anthropic /ai/v1/messages 端点。
|
// messages 是 Anthropic /ai/v1/messages 端点。
|
||||||
//
|
//
|
||||||
// @Summary Anthropic Messages 兼容端点
|
// @Summary Anthropic Messages 兼容端点
|
||||||
// @Tags AI 网关
|
// @Tags AI 网关
|
||||||
// @Param body body object true "Anthropic messages 请求体(支持 stream;经 OCI OpenAI 兼容面直通)"
|
// @Param body body aiwire.MessagesRequest true "Anthropic messages 请求体(支持 stream;经 OCI OpenAI 兼容面直通;max_tokens 可缺省,默认 8192)"
|
||||||
// @Success 200 {object} map[string]any "Anthropic 兼容响应(流式为 SSE)"
|
// @Success 200 {object} aiwire.MessagesResponse "Anthropic 兼容响应(非流式;流式为 SSE 事件序列)"
|
||||||
// @Router /ai/v1/messages [post]
|
// @Router /ai/v1/messages [post]
|
||||||
func (h *aiGatewayHandler) messages(c *gin.Context) {
|
func (h *aiGatewayHandler) messages(c *gin.Context) {
|
||||||
var req aiwire.MessagesRequest
|
var req aiwire.MessagesRequest
|
||||||
@@ -224,8 +228,7 @@ func (h *aiGatewayHandler) messages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.MaxTokens <= 0 {
|
if req.MaxTokens <= 0 {
|
||||||
aiError(c, http.StatusBadRequest, "invalid_request_error", "max_tokens 必填且需大于 0")
|
req.MaxTokens = anthDefaultMaxTokens
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if len(req.Messages) == 0 {
|
if len(req.Messages) == 0 {
|
||||||
aiError(c, http.StatusBadRequest, "invalid_request_error", "messages 不能为空")
|
aiError(c, http.StatusBadRequest, "invalid_request_error", "messages 不能为空")
|
||||||
@@ -281,10 +284,30 @@ func (h *aiGatewayHandler) streamAnthropic(c *gin.Context, body []byte, req aiwi
|
|||||||
defer upstream.Close()
|
defer upstream.Close()
|
||||||
sseHeaders(c)
|
sseHeaders(c)
|
||||||
bridge := service.NewAnthRespBridge(aiRandID("msg_"), req.Model)
|
bridge := service.NewAnthRespBridge(aiRandID("msg_"), req.Model)
|
||||||
if err := forwardSSEData(upstream, func(data []byte) { writeAnthEvents(c, bridge.Feed(data)) }); err != nil {
|
emitted := 0
|
||||||
|
if err := forwardSSEData(upstream, func(data []byte) {
|
||||||
|
evs := bridge.Feed(data)
|
||||||
|
emitted += len(evs)
|
||||||
|
writeAnthEvents(c, evs)
|
||||||
|
}); err != nil {
|
||||||
entry.ErrMsg = err.Error()
|
entry.ErrMsg = err.Error()
|
||||||
}
|
}
|
||||||
|
// 上游断流且客户端尚未收到任何事件(OCI 兼容面对大请求 + 推理模型的
|
||||||
|
// 流式通道会在 reasoning 阶段掐断):降级非流式重做,结果按事件序列推送
|
||||||
|
if emitted == 0 && !bridge.SawTerminal() && c.Request.Context().Err() == nil {
|
||||||
|
if h.anthFallback(c, &entry, req) {
|
||||||
|
entry.Status = http.StatusOK
|
||||||
|
entry.LatencyMs = time.Since(start).Milliseconds()
|
||||||
|
callID := h.gw.LogCall(entry)
|
||||||
|
h.maybeLogContent(c, callID, "anthropic", req.Model, true, req, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
writeAnthEvents(c, bridge.Finish())
|
writeAnthEvents(c, bridge.Finish())
|
||||||
|
// 上游错误事件 / 提前终止:SSE 头已发出维持 200,错误落日志可查
|
||||||
|
if msg := bridge.Err(); msg != "" && entry.ErrMsg == "" {
|
||||||
|
entry.ErrMsg = msg
|
||||||
|
}
|
||||||
entry.Status = http.StatusOK
|
entry.Status = http.StatusOK
|
||||||
entry.LatencyMs = time.Since(start).Milliseconds()
|
entry.LatencyMs = time.Since(start).Milliseconds()
|
||||||
usage := bridge.Usage()
|
usage := bridge.Usage()
|
||||||
@@ -295,6 +318,30 @@ func (h *aiGatewayHandler) streamAnthropic(c *gin.Context, body []byte, req aiwi
|
|||||||
h.maybeLogContent(c, callID, "anthropic", req.Model, true, req, nil)
|
h.maybeLogContent(c, callID, "anthropic", req.Model, true, req, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// anthFallback 用非流式重做同一请求并把完整结果按事件序列推送;
|
||||||
|
// 成功返回 true 并把渠道 / 用量 / 降级标注写入日志条目。
|
||||||
|
func (h *aiGatewayHandler) anthFallback(c *gin.Context, entry *model.AiCallLog, req aiwire.MessagesRequest) bool {
|
||||||
|
req.Stream = false
|
||||||
|
body, err := service.AnthropicToResponsesBody(req)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), body, req.Model, keyGroup(c))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
out, err := service.ResponsesToAnthropic(payload, aiRandID("msg_"))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
writeAnthEvents(c, service.AnthMessageEvents(out))
|
||||||
|
entry.ChannelID, entry.ChannelName = meta.ChannelID, meta.ChannelName
|
||||||
|
entry.Retries++
|
||||||
|
entry.ErrMsg = "流式上游断流,已降级非流式完成"
|
||||||
|
fillUsage(entry, service.RespPassthroughUsage(payload))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// forwardSSEData 逐行读上游 SSE,把 data 行交给 emit 即时转换写出;
|
// forwardSSEData 逐行读上游 SSE,把 data 行交给 emit 即时转换写出;
|
||||||
// Anthropic 与 Chat Completions 两条流式桥共用。
|
// Anthropic 与 Chat Completions 两条流式桥共用。
|
||||||
func forwardSSEData(upstream io.Reader, emit func([]byte)) error {
|
func forwardSSEData(upstream io.Reader, emit func([]byte)) error {
|
||||||
@@ -334,8 +381,8 @@ func writeAnthEvents(c *gin.Context, events []service.AnthEvent) {
|
|||||||
//
|
//
|
||||||
// @Summary OpenAI Chat Completions 兼容端点
|
// @Summary OpenAI Chat Completions 兼容端点
|
||||||
// @Tags AI 网关
|
// @Tags AI 网关
|
||||||
// @Param body body object true "OpenAI chat/completions 请求体(支持 stream;经 Responses 转换直通)"
|
// @Param body body aiwire.ChatRequest true "OpenAI chat/completions 请求体(支持 stream;经 Responses 转换直通)"
|
||||||
// @Success 200 {object} map[string]any "OpenAI 兼容响应(流式为 SSE,末尾 data: [DONE])"
|
// @Success 200 {object} aiwire.ChatResponse "OpenAI 兼容响应(非流式;流式为 SSE,末尾 data: [DONE])"
|
||||||
// @Router /ai/v1/chat/completions [post]
|
// @Router /ai/v1/chat/completions [post]
|
||||||
func (h *aiGatewayHandler) chatCompletions(c *gin.Context) {
|
func (h *aiGatewayHandler) chatCompletions(c *gin.Context) {
|
||||||
var req aiwire.ChatRequest
|
var req aiwire.ChatRequest
|
||||||
@@ -403,12 +450,31 @@ func (h *aiGatewayHandler) streamChat(c *gin.Context, body []byte, req aiwire.Ch
|
|||||||
sseHeaders(c)
|
sseHeaders(c)
|
||||||
includeUsage := req.StreamOptions != nil && req.StreamOptions.IncludeUsage
|
includeUsage := req.StreamOptions != nil && req.StreamOptions.IncludeUsage
|
||||||
bridge := service.NewChatRespBridge(aiRandID("chatcmpl-"), req.Model, time.Now().Unix(), includeUsage)
|
bridge := service.NewChatRespBridge(aiRandID("chatcmpl-"), req.Model, time.Now().Unix(), includeUsage)
|
||||||
if err := forwardSSEData(upstream, func(data []byte) { writeChatChunks(c, bridge.Feed(data)) }); err != nil {
|
emitted := 0
|
||||||
|
if err := forwardSSEData(upstream, func(data []byte) {
|
||||||
|
chunks := bridge.Feed(data)
|
||||||
|
emitted += len(chunks)
|
||||||
|
writeChatChunks(c, chunks)
|
||||||
|
}); err != nil {
|
||||||
entry.ErrMsg = err.Error()
|
entry.ErrMsg = err.Error()
|
||||||
}
|
}
|
||||||
|
// 上游断流且客户端尚未收到任何块:降级非流式重做(成因同 messages 端点)
|
||||||
|
if emitted == 0 && bridge.Usage() == nil && c.Request.Context().Err() == nil {
|
||||||
|
if h.chatFallback(c, &entry, req, includeUsage) {
|
||||||
|
entry.Status = http.StatusOK
|
||||||
|
entry.LatencyMs = time.Since(start).Milliseconds()
|
||||||
|
callID := h.gw.LogCall(entry)
|
||||||
|
h.maybeLogContent(c, callID, "openai", req.Model, true, req, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
writeChatChunks(c, bridge.Finish())
|
writeChatChunks(c, bridge.Finish())
|
||||||
c.Writer.WriteString("data: [DONE]\n\n")
|
c.Writer.WriteString("data: [DONE]\n\n")
|
||||||
c.Writer.Flush()
|
c.Writer.Flush()
|
||||||
|
// 未见 completed 即结束:usage 缺失说明上游流异常提前终止,落日志可查
|
||||||
|
if bridge.Usage() == nil && entry.ErrMsg == "" {
|
||||||
|
entry.ErrMsg = "上游流提前终止,未返回终态事件"
|
||||||
|
}
|
||||||
entry.Status = http.StatusOK
|
entry.Status = http.StatusOK
|
||||||
entry.LatencyMs = time.Since(start).Milliseconds()
|
entry.LatencyMs = time.Since(start).Milliseconds()
|
||||||
fillUsage(&entry, bridge.Usage())
|
fillUsage(&entry, bridge.Usage())
|
||||||
@@ -416,6 +482,32 @@ func (h *aiGatewayHandler) streamChat(c *gin.Context, body []byte, req aiwire.Ch
|
|||||||
h.maybeLogContent(c, callID, "openai", req.Model, true, req, nil)
|
h.maybeLogContent(c, callID, "openai", req.Model, true, req, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// chatFallback 用非流式重做同一请求并把完整结果按 chunk 序列推送;
|
||||||
|
// 成功返回 true 并把渠道 / 用量 / 降级标注写入日志条目。
|
||||||
|
func (h *aiGatewayHandler) chatFallback(c *gin.Context, entry *model.AiCallLog, req aiwire.ChatRequest, includeUsage bool) bool {
|
||||||
|
req.Stream = false
|
||||||
|
body, err := service.ChatToResponsesBody(req)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), body, req.Model, keyGroup(c))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
out, err := service.ResponsesToChat(payload, aiRandID("chatcmpl-"), time.Now().Unix())
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
writeChatChunks(c, service.ChatResponseChunks(out, includeUsage))
|
||||||
|
c.Writer.WriteString("data: [DONE]\n\n")
|
||||||
|
c.Writer.Flush()
|
||||||
|
entry.ChannelID, entry.ChannelName = meta.ChannelID, meta.ChannelName
|
||||||
|
entry.Retries++
|
||||||
|
entry.ErrMsg = "流式上游断流,已降级非流式完成"
|
||||||
|
fillUsage(entry, service.RespPassthroughUsage(payload))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// writeChatChunks 逐块写出 chunk 的 SSE data 行并 flush。
|
// writeChatChunks 逐块写出 chunk 的 SSE data 行并 flush。
|
||||||
func writeChatChunks(c *gin.Context, chunks []aiwire.ChatChunk) {
|
func writeChatChunks(c *gin.Context, chunks []aiwire.ChatChunk) {
|
||||||
for _, ch := range chunks {
|
for _, ch := range chunks {
|
||||||
@@ -436,7 +528,7 @@ func writeChatChunks(c *gin.Context, chunks []aiwire.ChatChunk) {
|
|||||||
//
|
//
|
||||||
// @Summary 可用模型列表
|
// @Summary 可用模型列表
|
||||||
// @Tags AI 网关
|
// @Tags AI 网关
|
||||||
// @Success 200 {object} map[string]any "OpenAI 兼容 models 列表"
|
// @Success 200 {object} aiwire.ModelList "OpenAI 兼容 models 列表"
|
||||||
// @Router /ai/v1/models [get]
|
// @Router /ai/v1/models [get]
|
||||||
func (h *aiGatewayHandler) listModels(c *gin.Context) {
|
func (h *aiGatewayHandler) listModels(c *gin.Context) {
|
||||||
list, err := h.gw.GatewayModels(c.Request.Context(), keyGroup(c))
|
list, err := h.gw.GatewayModels(c.Request.Context(), keyGroup(c))
|
||||||
@@ -460,8 +552,8 @@ func (h *aiGatewayHandler) listModels(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// @Summary OpenAI Responses 兼容端点
|
// @Summary OpenAI Responses 兼容端点
|
||||||
// @Tags AI 网关
|
// @Tags AI 网关
|
||||||
// @Param body body object true "OpenAI responses 请求体(支持 stream;web_search/x_search 服务端工具仅非流式)"
|
// @Param body body aiwire.RespRequest true "OpenAI responses 请求体(支持 stream;服务端工具 web_search/x_search/code_interpreter/mcp 含流式;未列字段原样透传上游)"
|
||||||
// @Success 200 {object} map[string]any "OpenAI 兼容响应(流式为 SSE)"
|
// @Success 200 {object} aiwire.RespResponse "OpenAI 兼容响应(非流式;流式为 SSE);直通仅建模常用字段,未列字段原样返回"
|
||||||
// @Router /ai/v1/responses [post]
|
// @Router /ai/v1/responses [post]
|
||||||
func (h *aiGatewayHandler) responses(c *gin.Context) {
|
func (h *aiGatewayHandler) responses(c *gin.Context) {
|
||||||
raw, err := c.GetRawData()
|
raw, err := c.GetRawData()
|
||||||
@@ -526,9 +618,14 @@ func (h *aiGatewayHandler) responsesPassthroughStream(c *gin.Context, body []byt
|
|||||||
}
|
}
|
||||||
defer upstream.Close()
|
defer upstream.Close()
|
||||||
sseHeaders(c)
|
sseHeaders(c)
|
||||||
usage, err := forwardSSE(c, upstream)
|
usage, upErr, err := forwardSSE(c, upstream)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
entry.ErrMsg = err.Error()
|
entry.ErrMsg = err.Error()
|
||||||
|
} else if upErr != "" {
|
||||||
|
// 上游错误事件已原样转发给客户端,这里落日志可查
|
||||||
|
entry.ErrMsg = upErr
|
||||||
|
} else if usage == nil {
|
||||||
|
entry.ErrMsg = "上游流提前终止,未返回终态事件"
|
||||||
}
|
}
|
||||||
entry.Status = http.StatusOK
|
entry.Status = http.StatusOK
|
||||||
entry.LatencyMs = time.Since(start).Milliseconds()
|
entry.LatencyMs = time.Since(start).Milliseconds()
|
||||||
@@ -538,10 +635,11 @@ func (h *aiGatewayHandler) responsesPassthroughStream(c *gin.Context, body []byt
|
|||||||
}
|
}
|
||||||
|
|
||||||
// forwardSSE 把上游 SSE 逐行转发给客户端,空行(事件边界)即 flush;
|
// forwardSSE 把上游 SSE 逐行转发给客户端,空行(事件边界)即 flush;
|
||||||
// 顺带从 data 行提取 response.completed 的 usage。
|
// 顺带从 data 行提取 response.completed 的 usage 与错误事件消息。
|
||||||
func forwardSSE(c *gin.Context, upstream io.Reader) (*aiwire.Usage, error) {
|
func forwardSSE(c *gin.Context, upstream io.Reader) (*aiwire.Usage, string, error) {
|
||||||
reader := bufio.NewReader(upstream)
|
reader := bufio.NewReader(upstream)
|
||||||
var usage *aiwire.Usage
|
var usage *aiwire.Usage
|
||||||
|
var upErr string
|
||||||
for {
|
for {
|
||||||
line, err := reader.ReadBytes('\n')
|
line, err := reader.ReadBytes('\n')
|
||||||
if len(line) > 0 {
|
if len(line) > 0 {
|
||||||
@@ -553,14 +651,17 @@ func forwardSSE(c *gin.Context, upstream io.Reader) (*aiwire.Usage, error) {
|
|||||||
if u := service.RespStreamCompletedUsage(data); u != nil {
|
if u := service.RespStreamCompletedUsage(data); u != nil {
|
||||||
usage = u
|
usage = u
|
||||||
}
|
}
|
||||||
|
if m := service.RespStreamErrorMsg(data); m != "" && upErr == "" {
|
||||||
|
upErr = m
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Writer.Flush()
|
c.Writer.Flush()
|
||||||
if errors.Is(err, io.EOF) {
|
if errors.Is(err, io.EOF) {
|
||||||
return usage, nil
|
return usage, upErr, nil
|
||||||
}
|
}
|
||||||
return usage, err
|
return usage, upErr, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"oci-portal/internal/aiwire"
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// audioSpeech 是 OpenAI /ai/v1/audio/speech 端点(TTS,直通兼容面)。
|
||||||
|
//
|
||||||
|
// @Summary OpenAI Audio Speech 兼容端点(文本转语音)
|
||||||
|
// @Tags AI 网关
|
||||||
|
// @Param body body aiwire.SpeechRequest true "OpenAI audio speech 请求体(model/input 必填,voice 见 xAI Grok Voice 列表,language 缺省 auto;未列字段原样透传)"
|
||||||
|
// @Success 200 {file} binary "音频字节(Content-Type 透传上游,默认 audio/mpeg)"
|
||||||
|
// @Router /ai/v1/audio/speech [post]
|
||||||
|
func (h *aiGatewayHandler) audioSpeech(c *gin.Context) {
|
||||||
|
raw, err := c.GetRawData()
|
||||||
|
if err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modelName, body, err := service.SpeechBodyNormalize(raw)
|
||||||
|
if err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.speechRespond(c, "speech", modelName, raw, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tts 是 xAI 官方格式 TTS 端点(/ai/v1/tts),转换为上游 OpenAI 兼容形态后复用 Speech 编排。
|
||||||
|
//
|
||||||
|
// @Summary xAI 官方格式文本转语音端点
|
||||||
|
// @Tags AI 网关
|
||||||
|
// @Param body body aiwire.TtsRequest true "xAI TTS 请求体(text/language 必填,voice_id 缺省 eve;model 为网关扩展,缺省 xai.grok-tts;未列字段原样透传)"
|
||||||
|
// @Success 200 {file} binary "音频字节(Content-Type 透传上游,默认 audio/mpeg)"
|
||||||
|
// @Router /ai/v1/tts [post]
|
||||||
|
func (h *aiGatewayHandler) tts(c *gin.Context) {
|
||||||
|
raw, err := c.GetRawData()
|
||||||
|
if err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modelName, body, err := service.TtsBodyConvert(raw)
|
||||||
|
if err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.speechRespond(c, "tts", modelName, raw, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// speechRespond 是 audioSpeech / tts 的公共主体:白名单校验、上游调用、日志与音频响应。
|
||||||
|
func (h *aiGatewayHandler) speechRespond(c *gin.Context, endpoint, modelName string, raw, body []byte) {
|
||||||
|
if !checkKeyModel(c, modelName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
audio, contentType, meta, err := h.gw.Speech(c.Request.Context(), modelName, body, keyGroup(c))
|
||||||
|
entry := h.logEntry(c, endpoint, modelName, false, meta, start)
|
||||||
|
if err != nil {
|
||||||
|
upstreamError(c, err)
|
||||||
|
entry.ErrMsg = err.Error()
|
||||||
|
h.logFailure(c, entry, string(raw))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry.Status = http.StatusOK
|
||||||
|
callID := h.gw.LogCall(entry)
|
||||||
|
h.maybeLogContent(c, callID, endpoint, modelName, false, string(raw), nil)
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "audio/mpeg"
|
||||||
|
}
|
||||||
|
c.Data(http.StatusOK, contentType, audio)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rerank 是 /ai/v1/rerank 端点(Jina / Cohere 风格文档重排)。
|
||||||
|
//
|
||||||
|
// @Summary 文档重排端点(Cohere Rerank)
|
||||||
|
// @Tags AI 网关
|
||||||
|
// @Param body body aiwire.RerankRequest true "重排请求体(model/query/documents 必填,可选 top_n/return_documents)"
|
||||||
|
// @Success 200 {object} aiwire.RerankResponse "重排结果(results[].index 指向入参下标)"
|
||||||
|
// @Router /ai/v1/rerank [post]
|
||||||
|
func (h *aiGatewayHandler) rerank(c *gin.Context) {
|
||||||
|
var req aiwire.RerankRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Model) == "" || strings.TrimSpace(req.Query) == "" || len(req.Documents) == 0 {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", "model、query 与 documents 不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !checkKeyModel(c, req.Model) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
resp, meta, err := h.gw.Rerank(c.Request.Context(), req, keyGroup(c))
|
||||||
|
entry := h.logEntry(c, "rerank", req.Model, false, meta, start)
|
||||||
|
if err != nil {
|
||||||
|
upstreamError(c, err)
|
||||||
|
entry.ErrMsg = err.Error()
|
||||||
|
h.logFailure(c, entry, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry.Status = http.StatusOK
|
||||||
|
callID := h.gw.LogCall(entry)
|
||||||
|
h.maybeLogContent(c, callID, "rerank", req.Model, false, req, resp)
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// moderations 是 /ai/v1/moderations 端点(OpenAI 外壳映射 OCI Guardrails)。
|
||||||
|
//
|
||||||
|
// @Summary 内容审核端点(OCI Guardrails)
|
||||||
|
// @Tags AI 网关
|
||||||
|
// @Param body body aiwire.ModerationsRequest true "审核请求体(input 为字符串或字符串数组,单次至多 8 条;model 接受但忽略)"
|
||||||
|
// @Success 200 {object} aiwire.ModerationsResponse "审核结果(categories/category_scores 为 overall/blocklist/prompt_injection,pii 为扩展字段)"
|
||||||
|
// @Router /ai/v1/moderations [post]
|
||||||
|
func (h *aiGatewayHandler) moderations(c *gin.Context) {
|
||||||
|
var req aiwire.ModerationsRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inputs, err := service.ModerationInputs(req.Input)
|
||||||
|
if err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
resp, meta, err := h.gw.Moderations(c.Request.Context(), aiRandID("modr_"), inputs, keyGroup(c))
|
||||||
|
entry := h.logEntry(c, "moderations", "oci-guardrails", false, meta, start)
|
||||||
|
if err != nil {
|
||||||
|
upstreamError(c, err)
|
||||||
|
entry.ErrMsg = err.Error()
|
||||||
|
h.logFailure(c, entry, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry.Status = http.StatusOK
|
||||||
|
callID := h.gw.LogCall(entry)
|
||||||
|
h.maybeLogContent(c, callID, "moderations", "oci-guardrails", false, req, resp)
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
@@ -85,6 +85,8 @@ func TestAiGatewayKeyModelRestrict(t *testing.T) {
|
|||||||
`{"model":"meta.llama-3.3-70b-instruct","messages":[{"role":"user","content":"hi"}]}`, 404, deny},
|
`{"model":"meta.llama-3.3-70b-instruct","messages":[{"role":"user","content":"hi"}]}`, 404, deny},
|
||||||
{"chat completions 白名单内穿透", "limited-key-1234", "/ai/v1/chat/completions",
|
{"chat completions 白名单内穿透", "limited-key-1234", "/ai/v1/chat/completions",
|
||||||
`{"model":"ghost-model","messages":[{"role":"user","content":"hi"}]}`, 404, pass},
|
`{"model":"ghost-model","messages":[{"role":"user","content":"hi"}]}`, 404, pass},
|
||||||
|
{"messages 缺 max_tokens 按默认值放行", "open-key-12345", "/ai/v1/messages",
|
||||||
|
`{"model":"ghost-model","messages":[{"role":"user","content":"hi"}]}`, 404, pass},
|
||||||
{"chat completions 缺 messages 拒绝", "open-key-12345", "/ai/v1/chat/completions",
|
{"chat completions 缺 messages 拒绝", "open-key-12345", "/ai/v1/chat/completions",
|
||||||
`{"model":"ghost-model"}`, 400, []string{"invalid_request_error"}},
|
`{"model":"ghost-model"}`, 400, []string{"invalid_request_error"}},
|
||||||
{"chat completions 非 function 工具拒绝", "open-key-12345", "/ai/v1/chat/completions",
|
{"chat completions 非 function 工具拒绝", "open-key-12345", "/ai/v1/chat/completions",
|
||||||
@@ -93,6 +95,18 @@ func TestAiGatewayKeyModelRestrict(t *testing.T) {
|
|||||||
{"chat completions 不支持内容块拒绝", "open-key-12345", "/ai/v1/chat/completions",
|
{"chat completions 不支持内容块拒绝", "open-key-12345", "/ai/v1/chat/completions",
|
||||||
`{"model":"ghost-model","messages":[{"role":"user","content":[{"type":"input_audio"}]}]}`,
|
`{"model":"ghost-model","messages":[{"role":"user","content":[{"type":"input_audio"}]}]}`,
|
||||||
400, []string{"invalid_request_error"}},
|
400, []string{"invalid_request_error"}},
|
||||||
|
{"audio speech 白名单外拦截", "limited-key-1234", "/ai/v1/audio/speech",
|
||||||
|
`{"model":"meta.llama-3.3-70b-instruct","input":"你好"}`, 404, deny},
|
||||||
|
{"rerank 白名单外拦截", "limited-key-1234", "/ai/v1/rerank",
|
||||||
|
`{"model":"meta.llama-3.3-70b-instruct","query":"q","documents":["d"]}`, 404, deny},
|
||||||
|
{"rerank 缺 documents 拒绝", "open-key-12345", "/ai/v1/rerank",
|
||||||
|
`{"model":"ghost-model","query":"q"}`, 400, []string{"invalid_request_error"}},
|
||||||
|
{"moderations 空 input 拒绝", "open-key-12345", "/ai/v1/moderations",
|
||||||
|
`{"input":[]}`, 400, []string{"invalid_request_error"}},
|
||||||
|
{"tts 缺 language 拒绝", "open-key-12345", "/ai/v1/tts",
|
||||||
|
`{"text":"你好"}`, 400, []string{"invalid_request_error"}},
|
||||||
|
{"tts 白名单外拦截", "limited-key-1234", "/ai/v1/tts",
|
||||||
|
`{"model":"meta.llama-3.3-70b-instruct","text":"你好","language":"zh"}`, 404, deny},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|||||||
+11
-11
@@ -15,7 +15,7 @@ import (
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Param body body object true "请求体(见接口说明)"
|
// @Param body body object true "请求体(见接口说明)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} publicIpResponse
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/change-public-ip [post]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/change-public-ip [post]
|
||||||
func (h *ociConfigHandler) changePublicIP(c *gin.Context) {
|
func (h *ociConfigHandler) changePublicIP(c *gin.Context) {
|
||||||
@@ -40,7 +40,7 @@ func (h *ociConfigHandler) changePublicIP(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Param body body object true "请求体(见接口说明)"
|
// @Param body body object true "请求体(见接口说明)"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} ipv6AddressResponse
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/ipv6-addresses [post]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/ipv6-addresses [post]
|
||||||
func (h *ociConfigHandler) addInstanceIpv6(c *gin.Context) {
|
func (h *ociConfigHandler) addInstanceIpv6(c *gin.Context) {
|
||||||
@@ -92,7 +92,7 @@ type attachVnicRequest struct {
|
|||||||
// @Tags 计算
|
// @Tags 计算
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.Vnic
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/vnics [get]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/vnics [get]
|
||||||
func (h *ociConfigHandler) listInstanceVnics(c *gin.Context) {
|
func (h *ociConfigHandler) listInstanceVnics(c *gin.Context) {
|
||||||
@@ -113,7 +113,7 @@ func (h *ociConfigHandler) listInstanceVnics(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Param body body attachVnicRequest true "请求体"
|
// @Param body body attachVnicRequest true "请求体"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} oci.Vnic
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/vnics [post]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/vnics [post]
|
||||||
func (h *ociConfigHandler) attachVnic(c *gin.Context) {
|
func (h *ociConfigHandler) attachVnic(c *gin.Context) {
|
||||||
@@ -160,7 +160,7 @@ func (h *ociConfigHandler) detachVnic(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param vnicId path string true "vnicId"
|
// @Param vnicId path string true "vnicId"
|
||||||
// @Param body body object true "请求体(见接口说明)"
|
// @Param body body object true "请求体(见接口说明)"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} addressResponse
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/vnics/{vnicId}/ipv6-addresses [post]
|
// @Router /api/v1/oci-configs/{id}/vnics/{vnicId}/ipv6-addresses [post]
|
||||||
func (h *ociConfigHandler) addVnicIpv6(c *gin.Context) {
|
func (h *ociConfigHandler) addVnicIpv6(c *gin.Context) {
|
||||||
@@ -195,7 +195,7 @@ type attachBootVolumeRequest struct {
|
|||||||
// @Tags 存储
|
// @Tags 存储
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.BootVolumeAttachment
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/boot-volume-attachments [get]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/boot-volume-attachments [get]
|
||||||
func (h *ociConfigHandler) listBootVolumeAttachments(c *gin.Context) {
|
func (h *ociConfigHandler) listBootVolumeAttachments(c *gin.Context) {
|
||||||
@@ -216,7 +216,7 @@ func (h *ociConfigHandler) listBootVolumeAttachments(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Param body body attachBootVolumeRequest true "请求体"
|
// @Param body body attachBootVolumeRequest true "请求体"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} oci.BootVolumeAttachment
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/boot-volume-attachments [post]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/boot-volume-attachments [post]
|
||||||
func (h *ociConfigHandler) attachBootVolume(c *gin.Context) {
|
func (h *ociConfigHandler) attachBootVolume(c *gin.Context) {
|
||||||
@@ -242,7 +242,7 @@ func (h *ociConfigHandler) attachBootVolume(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Param body body attachBootVolumeRequest true "请求体"
|
// @Param body body attachBootVolumeRequest true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.BootVolumeAttachment
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/replace-boot-volume [post]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/replace-boot-volume [post]
|
||||||
func (h *ociConfigHandler) replaceBootVolume(c *gin.Context) {
|
func (h *ociConfigHandler) replaceBootVolume(c *gin.Context) {
|
||||||
@@ -293,7 +293,7 @@ type attachVolumeRequest struct {
|
|||||||
// @Summary 块存储卷列表
|
// @Summary 块存储卷列表
|
||||||
// @Tags 存储
|
// @Tags 存储
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.BlockVolume
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/volumes [get]
|
// @Router /api/v1/oci-configs/{id}/volumes [get]
|
||||||
func (h *ociConfigHandler) listBlockVolumes(c *gin.Context) {
|
func (h *ociConfigHandler) listBlockVolumes(c *gin.Context) {
|
||||||
@@ -313,7 +313,7 @@ func (h *ociConfigHandler) listBlockVolumes(c *gin.Context) {
|
|||||||
// @Tags 存储
|
// @Tags 存储
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.VolumeAttachment
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/volume-attachments [get]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/volume-attachments [get]
|
||||||
func (h *ociConfigHandler) listVolumeAttachments(c *gin.Context) {
|
func (h *ociConfigHandler) listVolumeAttachments(c *gin.Context) {
|
||||||
@@ -334,7 +334,7 @@ func (h *ociConfigHandler) listVolumeAttachments(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Param body body attachVolumeRequest true "请求体"
|
// @Param body body attachVolumeRequest true "请求体"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} oci.VolumeAttachment
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/volume-attachments [post]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/volume-attachments [post]
|
||||||
func (h *ociConfigHandler) attachVolume(c *gin.Context) {
|
func (h *ociConfigHandler) attachVolume(c *gin.Context) {
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ type loginRequest struct {
|
|||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param body body loginRequest true "登录凭据"
|
// @Param body body loginRequest true "登录凭据"
|
||||||
// @Success 200 {object} map[string]any "token 与 expiresAt"
|
// @Success 200 {object} tokenResponse "token 与 expiresAt"
|
||||||
// @Failure 401 {object} map[string]string "凭据错误"
|
// @Failure 401 {object} errorResponse "凭据错误"
|
||||||
// @Failure 428 {object} map[string]any "需要两步验证码(totpRequired=true)"
|
// @Failure 428 {object} totpRequiredResponse "需要两步验证码(totpRequired=true)"
|
||||||
// @Router /api/v1/auth/login [post]
|
// @Router /api/v1/auth/login [post]
|
||||||
func (h *authHandler) login(c *gin.Context) {
|
func (h *authHandler) login(c *gin.Context) {
|
||||||
var req loginRequest
|
var req loginRequest
|
||||||
|
|||||||
+37
-12
@@ -5,9 +5,12 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
|
||||||
"oci-portal/internal/service"
|
"oci-portal/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,6 +18,7 @@ import (
|
|||||||
type authxHandler struct {
|
type authxHandler struct {
|
||||||
auth *service.AuthService
|
auth *service.AuthService
|
||||||
oauth *service.OAuthService
|
oauth *service.OAuthService
|
||||||
|
logs *service.SystemLogService
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- TOTP(JWT 组内) ----
|
// ---- TOTP(JWT 组内) ----
|
||||||
@@ -23,7 +27,7 @@ type authxHandler struct {
|
|||||||
//
|
//
|
||||||
// @Summary 两步验证状态
|
// @Summary 两步验证状态
|
||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Success 200 {object} map[string]bool "enabled"
|
// @Success 200 {object} enabledResponse "enabled"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/totp [get]
|
// @Router /api/v1/auth/totp [get]
|
||||||
func (h *authxHandler) totpStatus(c *gin.Context) {
|
func (h *authxHandler) totpStatus(c *gin.Context) {
|
||||||
@@ -39,8 +43,8 @@ func (h *authxHandler) totpStatus(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// @Summary 发起两步验证设置
|
// @Summary 发起两步验证设置
|
||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Success 200 {object} map[string]string "secret 与 otpauthUri"
|
// @Success 200 {object} totpSetupResponse "secret 与 otpauthUri"
|
||||||
// @Failure 409 {object} map[string]string "已启用"
|
// @Failure 409 {object} errorResponse "已启用"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/totp/setup [post]
|
// @Router /api/v1/auth/totp/setup [post]
|
||||||
func (h *authxHandler) totpSetup(c *gin.Context) {
|
func (h *authxHandler) totpSetup(c *gin.Context) {
|
||||||
@@ -128,7 +132,7 @@ func (h *authxHandler) respondFreshToken(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// @Summary 撤销全部会话
|
// @Summary 撤销全部会话
|
||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Success 200 {object} map[string]string "新 token 与 expiresAt"
|
// @Success 200 {object} tokenResponse "新 token 与 expiresAt"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/revoke-sessions [post]
|
// @Router /api/v1/auth/revoke-sessions [post]
|
||||||
func (h *authxHandler) revokeSessions(c *gin.Context) {
|
func (h *authxHandler) revokeSessions(c *gin.Context) {
|
||||||
@@ -146,7 +150,7 @@ func (h *authxHandler) revokeSessions(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// @Summary 登录凭据摘要
|
// @Summary 登录凭据摘要
|
||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Success 200 {object} map[string]any "username 与 passwordLoginDisabled"
|
// @Success 200 {object} credentialsResponse "username 与 passwordLoginDisabled"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/credentials [get]
|
// @Router /api/v1/auth/credentials [get]
|
||||||
func (h *authxHandler) getCredentials(c *gin.Context) {
|
func (h *authxHandler) getCredentials(c *gin.Context) {
|
||||||
@@ -167,7 +171,7 @@ func (h *authxHandler) getCredentials(c *gin.Context) {
|
|||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Param body body service.UpdateCredentialsInput true "新凭据(当前密码必验)"
|
// @Param body body service.UpdateCredentialsInput true "新凭据(当前密码必验)"
|
||||||
// @Success 204 "已更新,请重新登录"
|
// @Success 204 "已更新,请重新登录"
|
||||||
// @Failure 401 {object} map[string]string "当前密码错误"
|
// @Failure 401 {object} errorResponse "当前密码错误"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/credentials [put]
|
// @Router /api/v1/auth/credentials [put]
|
||||||
func (h *authxHandler) updateCredentials(c *gin.Context) {
|
func (h *authxHandler) updateCredentials(c *gin.Context) {
|
||||||
@@ -199,7 +203,7 @@ func (h *authxHandler) updateCredentials(c *gin.Context) {
|
|||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Param body body object true "{disabled: bool}"
|
// @Param body body object true "{disabled: bool}"
|
||||||
// @Success 204 "已保存"
|
// @Success 204 "已保存"
|
||||||
// @Failure 409 {object} map[string]string "未绑定外部身份"
|
// @Failure 409 {object} errorResponse "未绑定外部身份"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/password-login [put]
|
// @Router /api/v1/auth/password-login [put]
|
||||||
func (h *authxHandler) updatePasswordLogin(c *gin.Context) {
|
func (h *authxHandler) updatePasswordLogin(c *gin.Context) {
|
||||||
@@ -230,7 +234,7 @@ func (h *authxHandler) updatePasswordLogin(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// @Summary 外部登录 provider 列表
|
// @Summary 外部登录 provider 列表
|
||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Success 200 {object} map[string]any "providers 与 passwordLoginDisabled"
|
// @Success 200 {object} oauthProvidersResponse "providers 与 passwordLoginDisabled"
|
||||||
// @Router /api/v1/auth/oauth/providers [get]
|
// @Router /api/v1/auth/oauth/providers [get]
|
||||||
func (h *authxHandler) oauthProviders(c *gin.Context) {
|
func (h *authxHandler) oauthProviders(c *gin.Context) {
|
||||||
providers := h.oauth.Providers(c.Request.Context())
|
providers := h.oauth.Providers(c.Request.Context())
|
||||||
@@ -247,7 +251,7 @@ func (h *authxHandler) oauthProviders(c *gin.Context) {
|
|||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Param provider path string true "oidc / github"
|
// @Param provider path string true "oidc / github"
|
||||||
// @Param mode query string false "bind=绑定当前账号(需 Bearer),缺省登录"
|
// @Param mode query string false "bind=绑定当前账号(需 Bearer),缺省登录"
|
||||||
// @Success 200 {object} map[string]string "url"
|
// @Success 200 {object} urlResponse "url"
|
||||||
// @Router /api/v1/auth/oauth/{provider}/authorize [get]
|
// @Router /api/v1/auth/oauth/{provider}/authorize [get]
|
||||||
func (h *authxHandler) oauthAuthorize(c *gin.Context) {
|
func (h *authxHandler) oauthAuthorize(c *gin.Context) {
|
||||||
provider := c.Param("provider")
|
provider := c.Param("provider")
|
||||||
@@ -299,10 +303,12 @@ func (h *authxHandler) bearerUser(c *gin.Context) (string, bool) {
|
|||||||
// @Success 302 "登录 token 经 fragment 回前端,绑定回设置页"
|
// @Success 302 "登录 token 经 fragment 回前端,绑定回设置页"
|
||||||
// @Router /api/v1/auth/oauth/{provider}/callback [get]
|
// @Router /api/v1/auth/oauth/{provider}/callback [get]
|
||||||
func (h *authxHandler) oauthCallback(c *gin.Context) {
|
func (h *authxHandler) oauthCallback(c *gin.Context) {
|
||||||
|
start := time.Now()
|
||||||
provider := c.Param("provider")
|
provider := c.Param("provider")
|
||||||
token, _, mode, err := h.oauth.HandleCallback(
|
token, username, mode, err := h.oauth.HandleCallback(
|
||||||
c.Request.Context(), provider, c.Query("state"), c.Query("code"))
|
c.Request.Context(), provider, c.Query("state"), c.Query("code"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
h.recordOauth(c, username, http.StatusUnauthorized, oauthErrText(err), start)
|
||||||
target := "/login"
|
target := "/login"
|
||||||
if mode == "bind" {
|
if mode == "bind" {
|
||||||
target = "/settings"
|
target = "/settings"
|
||||||
@@ -310,6 +316,7 @@ func (h *authxHandler) oauthCallback(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusFound, target+"?oauthError="+url.QueryEscape(oauthErrText(err)))
|
c.Redirect(http.StatusFound, target+"?oauthError="+url.QueryEscape(oauthErrText(err)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.recordOauth(c, username, http.StatusOK, "", start)
|
||||||
if mode == "bind" {
|
if mode == "bind" {
|
||||||
// 绑定模式:版本已递增,新 token 经 fragment 带回设置页无感换发
|
// 绑定模式:版本已递增,新 token 经 fragment 带回设置页无感换发
|
||||||
c.Redirect(http.StatusFound, "/settings?oauth=bound#oauthToken="+url.QueryEscape(token))
|
c.Redirect(http.StatusFound, "/settings?oauth=bound#oauthToken="+url.QueryEscape(token))
|
||||||
@@ -319,6 +326,24 @@ func (h *authxHandler) oauthCallback(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusFound, "/login#oauthToken="+url.QueryEscape(token))
|
c.Redirect(http.StatusFound, "/login#oauthToken="+url.QueryEscape(token))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recordOauth 把外部登录 / 绑定结果记入系统日志——回调是 GET,
|
||||||
|
// 不经写方法中间件;实际响应恒为 302,日志按语义记 200 / 401。
|
||||||
|
func (h *authxHandler) recordOauth(c *gin.Context, username string, status int, errMsg string, start time.Time) {
|
||||||
|
if h.logs == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logs.Record(model.SystemLog{
|
||||||
|
Username: username,
|
||||||
|
Method: c.Request.Method,
|
||||||
|
Path: requestPath(c),
|
||||||
|
Status: status,
|
||||||
|
DurationMs: time.Since(start).Milliseconds(),
|
||||||
|
ClientIP: requestIP(c),
|
||||||
|
UserAgent: truncateLogField(c.Request.UserAgent(), 256),
|
||||||
|
ErrMsg: errMsg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// oauthErrText 把流程错误转为用户可读文案;内部错误不透出细节。
|
// oauthErrText 把流程错误转为用户可读文案;内部错误不透出细节。
|
||||||
func oauthErrText(err error) string {
|
func oauthErrText(err error) string {
|
||||||
for _, known := range []error{service.ErrOAuthNotConfigured, service.ErrOAuthNoAppURL, service.ErrOAuthDisabled, service.ErrOAuthState, service.ErrOAuthNotBound, service.ErrOAuthBound} {
|
for _, known := range []error{service.ErrOAuthNotConfigured, service.ErrOAuthNoAppURL, service.ErrOAuthDisabled, service.ErrOAuthState, service.ErrOAuthNotBound, service.ErrOAuthBound} {
|
||||||
@@ -333,7 +358,7 @@ func oauthErrText(err error) string {
|
|||||||
//
|
//
|
||||||
// @Summary 已绑定外部身份
|
// @Summary 已绑定外部身份
|
||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Success 200 {object} map[string]any "items"
|
// @Success 200 {object} itemsResponse[model.UserIdentity] "items"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/identities [get]
|
// @Router /api/v1/auth/identities [get]
|
||||||
func (h *authxHandler) identities(c *gin.Context) {
|
func (h *authxHandler) identities(c *gin.Context) {
|
||||||
@@ -351,7 +376,7 @@ func (h *authxHandler) identities(c *gin.Context) {
|
|||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Param id path int true "身份 ID"
|
// @Param id path int true "身份 ID"
|
||||||
// @Success 204 "已解绑"
|
// @Success 204 "已解绑"
|
||||||
// @Failure 409 {object} map[string]string "最后一个身份不可解绑"
|
// @Failure 409 {object} errorResponse "最后一个身份不可解绑"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/identities/{id} [delete]
|
// @Router /api/v1/auth/identities/{id} [delete]
|
||||||
func (h *authxHandler) unbindIdentity(c *gin.Context) {
|
func (h *authxHandler) unbindIdentity(c *gin.Context) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
_ "oci-portal/internal/oci" // swagger 注解引用
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---- 控制台连接(VNC / 串口) ----
|
// ---- 控制台连接(VNC / 串口) ----
|
||||||
@@ -18,7 +20,7 @@ type createConsoleConnectionRequest struct {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Param body body createConsoleConnectionRequest true "请求体"
|
// @Param body body createConsoleConnectionRequest true "请求体"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} oci.ConsoleConnection
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/console-connections [post]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/console-connections [post]
|
||||||
func (h *ociConfigHandler) createConsoleConnection(c *gin.Context) {
|
func (h *ociConfigHandler) createConsoleConnection(c *gin.Context) {
|
||||||
@@ -43,7 +45,7 @@ func (h *ociConfigHandler) createConsoleConnection(c *gin.Context) {
|
|||||||
// @Tags 计算
|
// @Tags 计算
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.ConsoleConnection
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/console-connections [get]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/console-connections [get]
|
||||||
func (h *ociConfigHandler) listConsoleConnections(c *gin.Context) {
|
func (h *ociConfigHandler) listConsoleConnections(c *gin.Context) {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func boolOr(v *bool, def bool) bool {
|
|||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.IdentityProviderInfo
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/identity-providers [get]
|
// @Router /api/v1/oci-configs/{id}/identity-providers [get]
|
||||||
func (h *ociConfigHandler) listIdentityProviders(c *gin.Context) {
|
func (h *ociConfigHandler) listIdentityProviders(c *gin.Context) {
|
||||||
@@ -62,7 +62,7 @@ func (h *ociConfigHandler) listIdentityProviders(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param body body createIdpRequest true "请求体"
|
// @Param body body createIdpRequest true "请求体"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} oci.IdentityProviderInfo
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/identity-providers [post]
|
// @Router /api/v1/oci-configs/{id}/identity-providers [post]
|
||||||
func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
|
func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
|
||||||
@@ -102,7 +102,7 @@ func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
|
|||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param idpId path string true "idpId"
|
// @Param idpId path string true "idpId"
|
||||||
// @Param body body object true "请求体(见接口说明)"
|
// @Param body body object true "请求体(见接口说明)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.IdentityProviderInfo
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/identity-providers/{idpId}/activate [post]
|
// @Router /api/v1/oci-configs/{id}/identity-providers/{idpId}/activate [post]
|
||||||
func (h *ociConfigHandler) activateIdentityProvider(c *gin.Context) {
|
func (h *ociConfigHandler) activateIdentityProvider(c *gin.Context) {
|
||||||
@@ -149,7 +149,7 @@ func (h *ociConfigHandler) deleteIdentityProvider(c *gin.Context) {
|
|||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {file} file "SAML 元数据 XML(附件下载)"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/saml-metadata [get]
|
// @Router /api/v1/oci-configs/{id}/saml-metadata [get]
|
||||||
func (h *ociConfigHandler) downloadSamlMetadata(c *gin.Context) {
|
func (h *ociConfigHandler) downloadSamlMetadata(c *gin.Context) {
|
||||||
@@ -170,7 +170,7 @@ func (h *ociConfigHandler) downloadSamlMetadata(c *gin.Context) {
|
|||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.SignOnRuleInfo
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/sign-on-rules [get]
|
// @Router /api/v1/oci-configs/{id}/sign-on-rules [get]
|
||||||
func (h *ociConfigHandler) listSignOnRules(c *gin.Context) {
|
func (h *ociConfigHandler) listSignOnRules(c *gin.Context) {
|
||||||
@@ -191,7 +191,7 @@ func (h *ociConfigHandler) listSignOnRules(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param body body object true "请求体(见接口说明)"
|
// @Param body body object true "请求体(见接口说明)"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} oci.SignOnRuleInfo
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/sign-on-exemptions [post]
|
// @Router /api/v1/oci-configs/{id}/sign-on-exemptions [post]
|
||||||
func (h *ociConfigHandler) createMfaExemption(c *gin.Context) {
|
func (h *ociConfigHandler) createMfaExemption(c *gin.Context) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
// @Summary 可用域列表
|
// @Summary 可用域列表
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} string
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/availability-domains [get]
|
// @Router /api/v1/oci-configs/{id}/availability-domains [get]
|
||||||
func (h *ociConfigHandler) availabilityDomains(c *gin.Context) {
|
func (h *ociConfigHandler) availabilityDomains(c *gin.Context) {
|
||||||
@@ -67,7 +67,7 @@ type instanceActionRequest struct {
|
|||||||
// @Summary 实例列表
|
// @Summary 实例列表
|
||||||
// @Tags 计算
|
// @Tags 计算
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.Instance
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances [get]
|
// @Router /api/v1/oci-configs/{id}/instances [get]
|
||||||
func (h *ociConfigHandler) listInstances(c *gin.Context) {
|
func (h *ociConfigHandler) listInstances(c *gin.Context) {
|
||||||
@@ -87,7 +87,7 @@ func (h *ociConfigHandler) listInstances(c *gin.Context) {
|
|||||||
// @Tags 计算
|
// @Tags 计算
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param body body createInstanceRequest true "请求体"
|
// @Param body body createInstanceRequest true "请求体"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} createInstancesResponse "部分成功仍 201,errors 为逐台失败信息"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances [post]
|
// @Router /api/v1/oci-configs/{id}/instances [post]
|
||||||
func (h *ociConfigHandler) createInstance(c *gin.Context) {
|
func (h *ociConfigHandler) createInstance(c *gin.Context) {
|
||||||
@@ -147,7 +147,7 @@ func (h *ociConfigHandler) createInstance(c *gin.Context) {
|
|||||||
// @Tags 计算
|
// @Tags 计算
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.Instance
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId} [get]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId} [get]
|
||||||
func (h *ociConfigHandler) getInstance(c *gin.Context) {
|
func (h *ociConfigHandler) getInstance(c *gin.Context) {
|
||||||
@@ -168,7 +168,7 @@ func (h *ociConfigHandler) getInstance(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Param body body updateInstanceRequest true "请求体"
|
// @Param body body updateInstanceRequest true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.Instance
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId} [put]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId} [put]
|
||||||
func (h *ociConfigHandler) updateInstance(c *gin.Context) {
|
func (h *ociConfigHandler) updateInstance(c *gin.Context) {
|
||||||
@@ -220,7 +220,7 @@ func (h *ociConfigHandler) terminateInstance(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Param body body instanceActionRequest true "请求体"
|
// @Param body body instanceActionRequest true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.Instance
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/action [post]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/action [post]
|
||||||
func (h *ociConfigHandler) instanceAction(c *gin.Context) {
|
func (h *ociConfigHandler) instanceAction(c *gin.Context) {
|
||||||
@@ -253,7 +253,7 @@ type updateBootVolumeRequest struct {
|
|||||||
// @Summary 引导卷列表
|
// @Summary 引导卷列表
|
||||||
// @Tags 存储
|
// @Tags 存储
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.BootVolume
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/boot-volumes [get]
|
// @Router /api/v1/oci-configs/{id}/boot-volumes [get]
|
||||||
func (h *ociConfigHandler) listBootVolumes(c *gin.Context) {
|
func (h *ociConfigHandler) listBootVolumes(c *gin.Context) {
|
||||||
@@ -273,7 +273,7 @@ func (h *ociConfigHandler) listBootVolumes(c *gin.Context) {
|
|||||||
// @Tags 存储
|
// @Tags 存储
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param bootVolumeId path string true "bootVolumeId"
|
// @Param bootVolumeId path string true "bootVolumeId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.BootVolume
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/boot-volumes/{bootVolumeId} [get]
|
// @Router /api/v1/oci-configs/{id}/boot-volumes/{bootVolumeId} [get]
|
||||||
func (h *ociConfigHandler) getBootVolume(c *gin.Context) {
|
func (h *ociConfigHandler) getBootVolume(c *gin.Context) {
|
||||||
@@ -294,7 +294,7 @@ func (h *ociConfigHandler) getBootVolume(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param bootVolumeId path string true "bootVolumeId"
|
// @Param bootVolumeId path string true "bootVolumeId"
|
||||||
// @Param body body updateBootVolumeRequest true "请求体"
|
// @Param body body updateBootVolumeRequest true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.BootVolume
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/boot-volumes/{bootVolumeId} [put]
|
// @Router /api/v1/oci-configs/{id}/boot-volumes/{bootVolumeId} [put]
|
||||||
func (h *ociConfigHandler) updateBootVolume(c *gin.Context) {
|
func (h *ociConfigHandler) updateBootVolume(c *gin.Context) {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
_ "oci-portal/internal/model" // swagger 注解引用
|
||||||
|
|
||||||
"oci-portal/internal/service"
|
"oci-portal/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,7 +22,7 @@ type logEventHandler struct {
|
|||||||
// @Summary 查询配置的回调地址
|
// @Summary 查询配置的回调地址
|
||||||
// @Tags 任务与日志回传
|
// @Tags 任务与日志回传
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} logWebhookStatusResponse
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/log-webhook [get]
|
// @Router /api/v1/oci-configs/{id}/log-webhook [get]
|
||||||
func (h *logEventHandler) getWebhook(c *gin.Context) {
|
func (h *logEventHandler) getWebhook(c *gin.Context) {
|
||||||
@@ -45,7 +47,7 @@ func (h *logEventHandler) getWebhook(c *gin.Context) {
|
|||||||
// @Summary 生成
|
// @Summary 生成
|
||||||
// @Tags 任务与日志回传
|
// @Tags 任务与日志回传
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.LogWebhookInfo
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/log-webhook [post]
|
// @Router /api/v1/oci-configs/{id}/log-webhook [post]
|
||||||
func (h *logEventHandler) ensureWebhook(c *gin.Context) {
|
func (h *logEventHandler) ensureWebhook(c *gin.Context) {
|
||||||
@@ -87,7 +89,7 @@ func (h *logEventHandler) revokeWebhook(c *gin.Context) {
|
|||||||
// @Tags 任务与日志回传
|
// @Tags 任务与日志回传
|
||||||
// @Param configId query int false "按配置过滤"
|
// @Param configId query int false "按配置过滤"
|
||||||
// @Param q query string false "关键字"
|
// @Param q query string false "关键字"
|
||||||
// @Success 200 {object} map[string]any "items 与 total"
|
// @Success 200 {object} pagedResponse[model.LogEvent] "items 与 total"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/log-events [get]
|
// @Router /api/v1/log-events [get]
|
||||||
func (h *logEventHandler) list(c *gin.Context) {
|
func (h *logEventHandler) list(c *gin.Context) {
|
||||||
@@ -111,7 +113,7 @@ func (h *logEventHandler) list(c *gin.Context) {
|
|||||||
// @Summary 查询 OCI 侧链路状态与关键事件清单
|
// @Summary 查询 OCI 侧链路状态与关键事件清单
|
||||||
// @Tags 任务与日志回传
|
// @Tags 任务与日志回传
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.RelayView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/log-relay [get]
|
// @Router /api/v1/oci-configs/{id}/log-relay [get]
|
||||||
func (h *logEventHandler) getRelay(c *gin.Context) {
|
func (h *logEventHandler) getRelay(c *gin.Context) {
|
||||||
@@ -132,7 +134,7 @@ func (h *logEventHandler) getRelay(c *gin.Context) {
|
|||||||
// @Summary 一键建立回传链路
|
// @Summary 一键建立回传链路
|
||||||
// @Tags 任务与日志回传
|
// @Tags 任务与日志回传
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.RelayView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/log-relay [post]
|
// @Router /api/v1/oci-configs/{id}/log-relay [post]
|
||||||
func (h *logEventHandler) setupRelay(c *gin.Context) {
|
func (h *logEventHandler) setupRelay(c *gin.Context) {
|
||||||
|
|||||||
+16
-16
@@ -11,7 +11,7 @@ import (
|
|||||||
// @Summary 实例形状列表
|
// @Summary 实例形状列表
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.ComputeShape
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/shapes [get]
|
// @Router /api/v1/oci-configs/{id}/shapes [get]
|
||||||
func (h *ociConfigHandler) shapes(c *gin.Context) {
|
func (h *ociConfigHandler) shapes(c *gin.Context) {
|
||||||
@@ -30,7 +30,7 @@ func (h *ociConfigHandler) shapes(c *gin.Context) {
|
|||||||
// @Summary 镜像列表
|
// @Summary 镜像列表
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.Image
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/images [get]
|
// @Router /api/v1/oci-configs/{id}/images [get]
|
||||||
func (h *ociConfigHandler) images(c *gin.Context) {
|
func (h *ociConfigHandler) images(c *gin.Context) {
|
||||||
@@ -54,7 +54,7 @@ func (h *ociConfigHandler) images(c *gin.Context) {
|
|||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param imageId path string true "imageId"
|
// @Param imageId path string true "imageId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.Image
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/images/{imageId} [get]
|
// @Router /api/v1/oci-configs/{id}/images/{imageId} [get]
|
||||||
func (h *ociConfigHandler) getImage(c *gin.Context) {
|
func (h *ociConfigHandler) getImage(c *gin.Context) {
|
||||||
@@ -89,7 +89,7 @@ type renameRequest struct {
|
|||||||
// @Summary VCN 列表
|
// @Summary VCN 列表
|
||||||
// @Tags 网络
|
// @Tags 网络
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.VCN
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/vcns [get]
|
// @Router /api/v1/oci-configs/{id}/vcns [get]
|
||||||
func (h *ociConfigHandler) listVCNs(c *gin.Context) {
|
func (h *ociConfigHandler) listVCNs(c *gin.Context) {
|
||||||
@@ -109,7 +109,7 @@ func (h *ociConfigHandler) listVCNs(c *gin.Context) {
|
|||||||
// @Tags 网络
|
// @Tags 网络
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param body body createVCNRequest true "请求体"
|
// @Param body body createVCNRequest true "请求体"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} oci.VCN
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/vcns [post]
|
// @Router /api/v1/oci-configs/{id}/vcns [post]
|
||||||
func (h *ociConfigHandler) createVCN(c *gin.Context) {
|
func (h *ociConfigHandler) createVCN(c *gin.Context) {
|
||||||
@@ -141,7 +141,7 @@ func (h *ociConfigHandler) createVCN(c *gin.Context) {
|
|||||||
// @Tags 网络
|
// @Tags 网络
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param vcnId path string true "vcnId"
|
// @Param vcnId path string true "vcnId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.VCN
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/vcns/{vcnId} [get]
|
// @Router /api/v1/oci-configs/{id}/vcns/{vcnId} [get]
|
||||||
func (h *ociConfigHandler) getVCN(c *gin.Context) {
|
func (h *ociConfigHandler) getVCN(c *gin.Context) {
|
||||||
@@ -162,7 +162,7 @@ func (h *ociConfigHandler) getVCN(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param vcnId path string true "vcnId"
|
// @Param vcnId path string true "vcnId"
|
||||||
// @Param body body renameRequest true "请求体"
|
// @Param body body renameRequest true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.VCN
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/vcns/{vcnId} [put]
|
// @Router /api/v1/oci-configs/{id}/vcns/{vcnId} [put]
|
||||||
func (h *ociConfigHandler) updateVCN(c *gin.Context) {
|
func (h *ociConfigHandler) updateVCN(c *gin.Context) {
|
||||||
@@ -211,7 +211,7 @@ type enableIPv6Request struct {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param vcnId path string true "vcnId"
|
// @Param vcnId path string true "vcnId"
|
||||||
// @Param body body enableIPv6Request true "请求体"
|
// @Param body body enableIPv6Request true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} ipv6StepsResponse
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/vcns/{vcnId}/enable-ipv6 [post]
|
// @Router /api/v1/oci-configs/{id}/vcns/{vcnId}/enable-ipv6 [post]
|
||||||
func (h *ociConfigHandler) enableVCNIPv6(c *gin.Context) {
|
func (h *ociConfigHandler) enableVCNIPv6(c *gin.Context) {
|
||||||
@@ -247,7 +247,7 @@ type createSubnetRequest struct {
|
|||||||
// @Summary 子网列表
|
// @Summary 子网列表
|
||||||
// @Tags 网络
|
// @Tags 网络
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.Subnet
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/subnets [get]
|
// @Router /api/v1/oci-configs/{id}/subnets [get]
|
||||||
func (h *ociConfigHandler) listSubnets(c *gin.Context) {
|
func (h *ociConfigHandler) listSubnets(c *gin.Context) {
|
||||||
@@ -267,7 +267,7 @@ func (h *ociConfigHandler) listSubnets(c *gin.Context) {
|
|||||||
// @Tags 网络
|
// @Tags 网络
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param body body createSubnetRequest true "请求体"
|
// @Param body body createSubnetRequest true "请求体"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} oci.Subnet
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/subnets [post]
|
// @Router /api/v1/oci-configs/{id}/subnets [post]
|
||||||
func (h *ociConfigHandler) createSubnet(c *gin.Context) {
|
func (h *ociConfigHandler) createSubnet(c *gin.Context) {
|
||||||
@@ -300,7 +300,7 @@ func (h *ociConfigHandler) createSubnet(c *gin.Context) {
|
|||||||
// @Tags 网络
|
// @Tags 网络
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param subnetId path string true "subnetId"
|
// @Param subnetId path string true "subnetId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.Subnet
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/subnets/{subnetId} [get]
|
// @Router /api/v1/oci-configs/{id}/subnets/{subnetId} [get]
|
||||||
func (h *ociConfigHandler) getSubnet(c *gin.Context) {
|
func (h *ociConfigHandler) getSubnet(c *gin.Context) {
|
||||||
@@ -321,7 +321,7 @@ func (h *ociConfigHandler) getSubnet(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param subnetId path string true "subnetId"
|
// @Param subnetId path string true "subnetId"
|
||||||
// @Param body body renameRequest true "请求体"
|
// @Param body body renameRequest true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.Subnet
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/subnets/{subnetId} [put]
|
// @Router /api/v1/oci-configs/{id}/subnets/{subnetId} [put]
|
||||||
func (h *ociConfigHandler) updateSubnet(c *gin.Context) {
|
func (h *ociConfigHandler) updateSubnet(c *gin.Context) {
|
||||||
@@ -381,7 +381,7 @@ type updateSecurityListRequest struct {
|
|||||||
// @Summary 安全列表清单
|
// @Summary 安全列表清单
|
||||||
// @Tags 网络
|
// @Tags 网络
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.SecurityList
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/security-lists [get]
|
// @Router /api/v1/oci-configs/{id}/security-lists [get]
|
||||||
func (h *ociConfigHandler) listSecurityLists(c *gin.Context) {
|
func (h *ociConfigHandler) listSecurityLists(c *gin.Context) {
|
||||||
@@ -401,7 +401,7 @@ func (h *ociConfigHandler) listSecurityLists(c *gin.Context) {
|
|||||||
// @Tags 网络
|
// @Tags 网络
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param body body createSecurityListRequest true "请求体"
|
// @Param body body createSecurityListRequest true "请求体"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} oci.SecurityList
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/security-lists [post]
|
// @Router /api/v1/oci-configs/{id}/security-lists [post]
|
||||||
func (h *ociConfigHandler) createSecurityList(c *gin.Context) {
|
func (h *ociConfigHandler) createSecurityList(c *gin.Context) {
|
||||||
@@ -432,7 +432,7 @@ func (h *ociConfigHandler) createSecurityList(c *gin.Context) {
|
|||||||
// @Tags 网络
|
// @Tags 网络
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param securityListId path string true "securityListId"
|
// @Param securityListId path string true "securityListId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.SecurityList
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/security-lists/{securityListId} [get]
|
// @Router /api/v1/oci-configs/{id}/security-lists/{securityListId} [get]
|
||||||
func (h *ociConfigHandler) getSecurityList(c *gin.Context) {
|
func (h *ociConfigHandler) getSecurityList(c *gin.Context) {
|
||||||
@@ -453,7 +453,7 @@ func (h *ociConfigHandler) getSecurityList(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param securityListId path string true "securityListId"
|
// @Param securityListId path string true "securityListId"
|
||||||
// @Param body body updateSecurityListRequest true "请求体"
|
// @Param body body updateSecurityListRequest true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.SecurityList
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/security-lists/{securityListId} [put]
|
// @Router /api/v1/oci-configs/{id}/security-lists/{securityListId} [put]
|
||||||
func (h *ociConfigHandler) updateSecurityList(c *gin.Context) {
|
func (h *ociConfigHandler) updateSecurityList(c *gin.Context) {
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/oracle/oci-go-sdk/v65/common"
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
_ "oci-portal/internal/model" // swagger 注解引用
|
||||||
|
|
||||||
"oci-portal/internal/oci"
|
"oci-portal/internal/oci"
|
||||||
"oci-portal/internal/service"
|
"oci-portal/internal/service"
|
||||||
@@ -39,7 +41,7 @@ type importRequest struct {
|
|||||||
// @Summary 导入租户配置
|
// @Summary 导入租户配置
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param body body importRequest true "API Key 配置(私钥密文落库)"
|
// @Param body body importRequest true "API Key 配置(私钥密文落库)"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} model.OciConfig
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs [post]
|
// @Router /api/v1/oci-configs [post]
|
||||||
func (h *ociConfigHandler) create(c *gin.Context) {
|
func (h *ociConfigHandler) create(c *gin.Context) {
|
||||||
@@ -71,7 +73,7 @@ func (h *ociConfigHandler) create(c *gin.Context) {
|
|||||||
|
|
||||||
// @Summary 租户配置列表
|
// @Summary 租户配置列表
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Success 200 {object} map[string]any "items"
|
// @Success 200 {array} service.ConfigSummary
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs [get]
|
// @Router /api/v1/oci-configs [get]
|
||||||
func (h *ociConfigHandler) list(c *gin.Context) {
|
func (h *ociConfigHandler) list(c *gin.Context) {
|
||||||
@@ -86,7 +88,7 @@ func (h *ociConfigHandler) list(c *gin.Context) {
|
|||||||
// @Summary 租户配置详情
|
// @Summary 租户配置详情
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} model.OciConfig
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id} [get]
|
// @Router /api/v1/oci-configs/{id} [get]
|
||||||
func (h *ociConfigHandler) get(c *gin.Context) {
|
func (h *ociConfigHandler) get(c *gin.Context) {
|
||||||
@@ -105,7 +107,7 @@ func (h *ociConfigHandler) get(c *gin.Context) {
|
|||||||
// @Summary 验证配置连通性并刷新画像
|
// @Summary 验证配置连通性并刷新画像
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} configChangesResponse "config 与 changes 字段变更对照"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/verify [post]
|
// @Router /api/v1/oci-configs/{id}/verify [post]
|
||||||
func (h *ociConfigHandler) verify(c *gin.Context) {
|
func (h *ociConfigHandler) verify(c *gin.Context) {
|
||||||
@@ -139,7 +141,7 @@ type updateConfigRequest struct {
|
|||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param body body object true "可更新字段(别名/分组/代理/多区域开关等)"
|
// @Param body body object true "可更新字段(别名/分组/代理/多区域开关等)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} configChangesResponse "config 与 changes 字段变更对照"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id} [put]
|
// @Router /api/v1/oci-configs/{id} [put]
|
||||||
func (h *ociConfigHandler) update(c *gin.Context) {
|
func (h *ociConfigHandler) update(c *gin.Context) {
|
||||||
@@ -194,7 +196,7 @@ func (h *ociConfigHandler) remove(c *gin.Context) {
|
|||||||
// @Summary 列出租户下全部 ACTIVE compartment
|
// @Summary 列出租户下全部 ACTIVE compartment
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.Compartment
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/compartments [get]
|
// @Router /api/v1/oci-configs/{id}/compartments [get]
|
||||||
func (h *ociConfigHandler) compartments(c *gin.Context) {
|
func (h *ociConfigHandler) compartments(c *gin.Context) {
|
||||||
@@ -216,7 +218,7 @@ func (h *ociConfigHandler) compartments(c *gin.Context) {
|
|||||||
// @Summary 返回筛选器用区域列表:未开多区域支持只含默认区域
|
// @Summary 返回筛选器用区域列表:未开多区域支持只含默认区域
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} service.RegionSubscriptionView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/cached-regions [get]
|
// @Router /api/v1/oci-configs/{id}/cached-regions [get]
|
||||||
func (h *ociConfigHandler) cachedRegions(c *gin.Context) {
|
func (h *ociConfigHandler) cachedRegions(c *gin.Context) {
|
||||||
@@ -237,7 +239,7 @@ func (h *ociConfigHandler) cachedRegions(c *gin.Context) {
|
|||||||
// @Summary 返回筛选器用区间列表:未开多区间支持返回空数组
|
// @Summary 返回筛选器用区间列表:未开多区间支持返回空数组
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.Compartment
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/cached-compartments [get]
|
// @Router /api/v1/oci-configs/{id}/cached-compartments [get]
|
||||||
func (h *ociConfigHandler) cachedCompartments(c *gin.Context) {
|
func (h *ociConfigHandler) cachedCompartments(c *gin.Context) {
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
_ "oci-portal/internal/service" // swagger 注解引用
|
||||||
)
|
)
|
||||||
|
|
||||||
// overview 返回总览页聚合数据(本地快照,不发云端请求)。
|
// overview 返回总览页聚合数据(本地快照,不发云端请求)。
|
||||||
//
|
//
|
||||||
// @Summary 返回总览页聚合数据
|
// @Summary 返回总览页聚合数据
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.Overview
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/overview [get]
|
// @Router /api/v1/overview [get]
|
||||||
func (h *ociConfigHandler) overview(c *gin.Context) {
|
func (h *ociConfigHandler) overview(c *gin.Context) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ type proxyHandler struct {
|
|||||||
//
|
//
|
||||||
// @Summary 代理列表
|
// @Summary 代理列表
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Success 200 {object} map[string]any "items"
|
// @Success 200 {object} itemsResponse[service.ProxyView] "items"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/proxies [get]
|
// @Router /api/v1/proxies [get]
|
||||||
func (h *proxyHandler) list(c *gin.Context) {
|
func (h *proxyHandler) list(c *gin.Context) {
|
||||||
@@ -35,7 +35,7 @@ func (h *proxyHandler) list(c *gin.Context) {
|
|||||||
// @Summary 创建代理
|
// @Summary 创建代理
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Param body body service.ProxyInput true "代理配置(密码只写不回)"
|
// @Param body body service.ProxyInput true "代理配置(密码只写不回)"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} service.ProxyView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/proxies [post]
|
// @Router /api/v1/proxies [post]
|
||||||
func (h *proxyHandler) create(c *gin.Context) {
|
func (h *proxyHandler) create(c *gin.Context) {
|
||||||
@@ -58,7 +58,7 @@ func (h *proxyHandler) create(c *gin.Context) {
|
|||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Param id path int true "代理 ID"
|
// @Param id path int true "代理 ID"
|
||||||
// @Param body body service.ProxyInput true "代理配置(密码缺省沿用)"
|
// @Param body body service.ProxyInput true "代理配置(密码缺省沿用)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.ProxyView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/proxies/{id} [put]
|
// @Router /api/v1/proxies/{id} [put]
|
||||||
func (h *proxyHandler) update(c *gin.Context) {
|
func (h *proxyHandler) update(c *gin.Context) {
|
||||||
@@ -104,7 +104,7 @@ func (h *proxyHandler) remove(c *gin.Context) {
|
|||||||
// @Summary 批量导入代理
|
// @Summary 批量导入代理
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Param body body object true "请求体(见接口说明)"
|
// @Param body body object true "请求体(见接口说明)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.ImportResult
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/proxies/import [post]
|
// @Router /api/v1/proxies/import [post]
|
||||||
func (h *proxyHandler) importBatch(c *gin.Context) {
|
func (h *proxyHandler) importBatch(c *gin.Context) {
|
||||||
@@ -128,7 +128,7 @@ func (h *proxyHandler) importBatch(c *gin.Context) {
|
|||||||
// @Summary 手动重测代理出口地区,同步返回最新视图
|
// @Summary 手动重测代理出口地区,同步返回最新视图
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.ProxyView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/proxies/{id}/probe [post]
|
// @Router /api/v1/proxies/{id}/probe [post]
|
||||||
func (h *proxyHandler) probe(c *gin.Context) {
|
func (h *proxyHandler) probe(c *gin.Context) {
|
||||||
@@ -150,7 +150,7 @@ func (h *proxyHandler) probe(c *gin.Context) {
|
|||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Param id path int true "代理 ID"
|
// @Param id path int true "代理 ID"
|
||||||
// @Param body body object true "请求体 {\"ociConfigIds\": [1,2]}"
|
// @Param body body object true "请求体 {\"ociConfigIds\": [1,2]}"
|
||||||
// @Success 200 {object} map[string]any "usedBy"
|
// @Success 200 {object} usedByResponse "usedBy"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/proxies/{id}/tenants [put]
|
// @Router /api/v1/proxies/{id}/tenants [put]
|
||||||
func (h *proxyHandler) setTenants(c *gin.Context) {
|
func (h *proxyHandler) setTenants(c *gin.Context) {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
_ "oci-portal/internal/service" // swagger 注解引用
|
||||||
|
|
||||||
"oci-portal/internal/oci"
|
"oci-portal/internal/oci"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,7 +15,7 @@ import (
|
|||||||
//
|
//
|
||||||
// @Summary 返回本地维护的完整区域表
|
// @Summary 返回本地维护的完整区域表
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.RegionInfo
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/regions [get]
|
// @Router /api/v1/regions [get]
|
||||||
func listRegions(c *gin.Context) {
|
func listRegions(c *gin.Context) {
|
||||||
@@ -28,7 +30,7 @@ func listRegions(c *gin.Context) {
|
|||||||
// @Summary 区域订阅列表
|
// @Summary 区域订阅列表
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} service.RegionSubscriptionView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/region-subscriptions [get]
|
// @Router /api/v1/oci-configs/{id}/region-subscriptions [get]
|
||||||
func (h *ociConfigHandler) regionSubscriptions(c *gin.Context) {
|
func (h *ociConfigHandler) regionSubscriptions(c *gin.Context) {
|
||||||
@@ -52,7 +54,7 @@ type subscribeRegionRequest struct {
|
|||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param body body subscribeRegionRequest true "请求体"
|
// @Param body body subscribeRegionRequest true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} service.RegionSubscriptionView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/region-subscriptions [post]
|
// @Router /api/v1/oci-configs/{id}/region-subscriptions [post]
|
||||||
func (h *ociConfigHandler) subscribeRegion(c *gin.Context) {
|
func (h *ociConfigHandler) subscribeRegion(c *gin.Context) {
|
||||||
@@ -76,7 +78,7 @@ func (h *ociConfigHandler) subscribeRegion(c *gin.Context) {
|
|||||||
// @Summary 服务限额查询
|
// @Summary 服务限额查询
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.LimitValue
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/limits [get]
|
// @Router /api/v1/oci-configs/{id}/limits [get]
|
||||||
func (h *ociConfigHandler) limits(c *gin.Context) {
|
func (h *ociConfigHandler) limits(c *gin.Context) {
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ func registerAiGateway(r *gin.Engine, aiGateway *service.AiGatewayService) {
|
|||||||
ai.POST("/responses", aih.responses)
|
ai.POST("/responses", aih.responses)
|
||||||
ai.POST("/messages", aih.messages)
|
ai.POST("/messages", aih.messages)
|
||||||
ai.POST("/embeddings", aih.embeddings)
|
ai.POST("/embeddings", aih.embeddings)
|
||||||
|
ai.POST("/audio/speech", aih.audioSpeech)
|
||||||
|
ai.POST("/tts", aih.tts)
|
||||||
|
ai.POST("/rerank", aih.rerank)
|
||||||
|
ai.POST("/moderations", aih.moderations)
|
||||||
ai.GET("/models", aih.listModels)
|
ai.GET("/models", aih.listModels)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +37,8 @@ func registerAiAdmin(secured *gin.RouterGroup, aiGateway *service.AiGatewayServi
|
|||||||
secured.POST("/ai-channels/:id/probe", aiadmin.probeChannel)
|
secured.POST("/ai-channels/:id/probe", aiadmin.probeChannel)
|
||||||
secured.POST("/ai-channels/:id/sync-models", aiadmin.syncChannelModels)
|
secured.POST("/ai-channels/:id/sync-models", aiadmin.syncChannelModels)
|
||||||
secured.GET("/ai-models", aiadmin.gatewayModels)
|
secured.GET("/ai-models", aiadmin.gatewayModels)
|
||||||
|
secured.GET("/ai-settings", aiadmin.aiSettings)
|
||||||
|
secured.PUT("/ai-settings", aiadmin.updateAiSettings)
|
||||||
secured.GET("/ai-blacklist", aiadmin.listBlacklist)
|
secured.GET("/ai-blacklist", aiadmin.listBlacklist)
|
||||||
secured.POST("/ai-blacklist", aiadmin.addBlacklist)
|
secured.POST("/ai-blacklist", aiadmin.addBlacklist)
|
||||||
secured.DELETE("/ai-blacklist/:id", aiadmin.removeBlacklist)
|
secured.DELETE("/ai-blacklist/:id", aiadmin.removeBlacklist)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ func registerAuthPublic(v1 *gin.RouterGroup, auth *service.AuthService, oauth *s
|
|||||||
v1.POST("/auth/login", ah.login)
|
v1.POST("/auth/login", ah.login)
|
||||||
|
|
||||||
// 外部身份登录:provider 列表 / 授权跳转(bind 模式 handler 内校验 JWT)/ 回调
|
// 外部身份登录:provider 列表 / 授权跳转(bind 模式 handler 内校验 JWT)/ 回调
|
||||||
ax := &authxHandler{auth: auth, oauth: oauth}
|
ax := &authxHandler{auth: auth, oauth: oauth, logs: systemLogs}
|
||||||
v1.GET("/auth/oauth/providers", ax.oauthProviders)
|
v1.GET("/auth/oauth/providers", ax.oauthProviders)
|
||||||
v1.GET("/auth/oauth/:provider/authorize", ax.oauthAuthorize)
|
v1.GET("/auth/oauth/:provider/authorize", ax.oauthAuthorize)
|
||||||
v1.GET("/auth/oauth/:provider/callback", ax.oauthCallback)
|
v1.GET("/auth/oauth/:provider/callback", ax.oauthCallback)
|
||||||
|
|||||||
+11
-11
@@ -19,7 +19,7 @@ type settingsHandler struct {
|
|||||||
//
|
//
|
||||||
// @Summary 返回脱敏后的 Telegram 配置,绝不回 token 明文
|
// @Summary 返回脱敏后的 Telegram 配置,绝不回 token 明文
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.TelegramView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/settings/telegram [get]
|
// @Router /api/v1/settings/telegram [get]
|
||||||
func (h *settingsHandler) getTelegram(c *gin.Context) {
|
func (h *settingsHandler) getTelegram(c *gin.Context) {
|
||||||
@@ -44,7 +44,7 @@ type updateTelegramRequest struct {
|
|||||||
// @Summary 保存配置并返回最新脱敏视图
|
// @Summary 保存配置并返回最新脱敏视图
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Param body body updateTelegramRequest true "请求体"
|
// @Param body body updateTelegramRequest true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.TelegramView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/settings/telegram [put]
|
// @Router /api/v1/settings/telegram [put]
|
||||||
func (h *settingsHandler) updateTelegram(c *gin.Context) {
|
func (h *settingsHandler) updateTelegram(c *gin.Context) {
|
||||||
@@ -84,7 +84,7 @@ func (h *settingsHandler) testTelegram(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// @Summary 返回全部通知渠道的脱敏视图
|
// @Summary 返回全部通知渠道的脱敏视图
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} itemsResponse[service.NotifyChannelView]
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/settings/notify-channels [get]
|
// @Router /api/v1/settings/notify-channels [get]
|
||||||
func (h *settingsHandler) listNotifyChannels(c *gin.Context) {
|
func (h *settingsHandler) listNotifyChannels(c *gin.Context) {
|
||||||
@@ -119,7 +119,7 @@ type updateNotifyChannelRequest struct {
|
|||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Param type path string true "渠道类型(webhook/ntfy/bark/smtp)"
|
// @Param type path string true "渠道类型(webhook/ntfy/bark/smtp)"
|
||||||
// @Param body body updateNotifyChannelRequest true "请求体"
|
// @Param body body updateNotifyChannelRequest true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} itemsResponse[service.NotifyChannelView]
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/settings/notify-channels/{type} [put]
|
// @Router /api/v1/settings/notify-channels/{type} [put]
|
||||||
func (h *settingsHandler) updateNotifyChannel(c *gin.Context) {
|
func (h *settingsHandler) updateNotifyChannel(c *gin.Context) {
|
||||||
@@ -160,7 +160,7 @@ func (h *settingsHandler) testNotifyChannel(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// @Summary 返回全部通知事件开关
|
// @Summary 返回全部通知事件开关
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.NotifyEventsView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/settings/notify-events [get]
|
// @Router /api/v1/settings/notify-events [get]
|
||||||
func (h *settingsHandler) getNotifyEvents(c *gin.Context) {
|
func (h *settingsHandler) getNotifyEvents(c *gin.Context) {
|
||||||
@@ -178,7 +178,7 @@ func (h *settingsHandler) getNotifyEvents(c *gin.Context) {
|
|||||||
// @Summary 全量保存五个事件开关并返回最新值
|
// @Summary 全量保存五个事件开关并返回最新值
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Param body body service.NotifyEventsView true "请求体"
|
// @Param body body service.NotifyEventsView true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.NotifyEventsView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/settings/notify-events [put]
|
// @Router /api/v1/settings/notify-events [put]
|
||||||
func (h *settingsHandler) updateNotifyEvents(c *gin.Context) {
|
func (h *settingsHandler) updateNotifyEvents(c *gin.Context) {
|
||||||
@@ -198,7 +198,7 @@ func (h *settingsHandler) updateNotifyEvents(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// @Summary 返回全部通知模板
|
// @Summary 返回全部通知模板
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} itemsResponse[service.NotifyTemplateView]
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/settings/notify-templates [get]
|
// @Router /api/v1/settings/notify-templates [get]
|
||||||
func (h *settingsHandler) listNotifyTemplates(c *gin.Context) {
|
func (h *settingsHandler) listNotifyTemplates(c *gin.Context) {
|
||||||
@@ -263,7 +263,7 @@ func (h *settingsHandler) testNotifyTemplate(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// @Summary 返回任务行为设置
|
// @Summary 返回任务行为设置
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.TaskSettingsView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/settings/task [get]
|
// @Router /api/v1/settings/task [get]
|
||||||
func (h *settingsHandler) getTaskSettings(c *gin.Context) {
|
func (h *settingsHandler) getTaskSettings(c *gin.Context) {
|
||||||
@@ -280,7 +280,7 @@ func (h *settingsHandler) getTaskSettings(c *gin.Context) {
|
|||||||
// @Summary 保存任务行为设置并返回最新值
|
// @Summary 保存任务行为设置并返回最新值
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Param body body service.TaskSettingsView true "请求体"
|
// @Param body body service.TaskSettingsView true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.TaskSettingsView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/settings/task [put]
|
// @Router /api/v1/settings/task [put]
|
||||||
func (h *settingsHandler) updateTaskSettings(c *gin.Context) {
|
func (h *settingsHandler) updateTaskSettings(c *gin.Context) {
|
||||||
@@ -304,7 +304,7 @@ func (h *settingsHandler) updateTaskSettings(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// @Summary 返回安全设置
|
// @Summary 返回安全设置
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.SecuritySettings
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/settings/security [get]
|
// @Router /api/v1/settings/security [get]
|
||||||
func (h *settingsHandler) getSecurity(c *gin.Context) {
|
func (h *settingsHandler) getSecurity(c *gin.Context) {
|
||||||
@@ -321,7 +321,7 @@ func (h *settingsHandler) getSecurity(c *gin.Context) {
|
|||||||
// @Summary 保存安全设置并返回最新值
|
// @Summary 保存安全设置并返回最新值
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Param body body service.SecuritySettings true "请求体"
|
// @Param body body service.SecuritySettings true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.SecuritySettings
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/settings/security [put]
|
// @Router /api/v1/settings/security [put]
|
||||||
func (h *settingsHandler) updateSecurity(c *gin.Context) {
|
func (h *settingsHandler) updateSecurity(c *gin.Context) {
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
_ "oci-portal/internal/oci" // swagger 注解引用
|
||||||
)
|
)
|
||||||
|
|
||||||
// @Summary 订阅列表
|
// @Summary 订阅列表
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.SubscriptionInfo
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/subscriptions [get]
|
// @Router /api/v1/oci-configs/{id}/subscriptions [get]
|
||||||
func (h *ociConfigHandler) subscriptions(c *gin.Context) {
|
func (h *ociConfigHandler) subscriptions(c *gin.Context) {
|
||||||
@@ -29,7 +31,7 @@ func (h *ociConfigHandler) subscriptions(c *gin.Context) {
|
|||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param subscriptionId path string true "subscriptionId"
|
// @Param subscriptionId path string true "subscriptionId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.SubscriptionDetail
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/subscriptions/{subscriptionId} [get]
|
// @Router /api/v1/oci-configs/{id}/subscriptions/{subscriptionId} [get]
|
||||||
func (h *ociConfigHandler) subscriptionDetail(c *gin.Context) {
|
func (h *ociConfigHandler) subscriptionDetail(c *gin.Context) {
|
||||||
@@ -48,7 +50,7 @@ func (h *ociConfigHandler) subscriptionDetail(c *gin.Context) {
|
|||||||
// @Summary 限额服务列表
|
// @Summary 限额服务列表
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.LimitService
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/limits/services [get]
|
// @Router /api/v1/oci-configs/{id}/limits/services [get]
|
||||||
func (h *ociConfigHandler) limitServices(c *gin.Context) {
|
func (h *ociConfigHandler) limitServices(c *gin.Context) {
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 本文件的类型仅供 swagger 文档渲染(@Success/@Failure 注解引用),
|
||||||
|
// 运行时代码不使用;字段与各 handler 返回的 gin.H 外壳保持一致。
|
||||||
|
|
||||||
|
// errorResponse 是统一错误响应外壳。
|
||||||
|
type errorResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// itemsResponse 是 {"items": [...]} 列表外壳。
|
||||||
|
type itemsResponse[T any] struct {
|
||||||
|
Items []T `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// pagedResponse 是 {"items": [...], "total": n} 分页外壳。
|
||||||
|
type pagedResponse[T any] struct {
|
||||||
|
Items []T `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// itemResponse 是 {"item": {...}} 单项外壳。
|
||||||
|
type itemResponse[T any] struct {
|
||||||
|
Item T `json:"item"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// triggerResponse 是任务触发受理响应。
|
||||||
|
type triggerResponse struct {
|
||||||
|
Triggered bool `json:"triggered"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenResponse 是登录 / 换发令牌响应。
|
||||||
|
type tokenResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// totpRequiredResponse 是密码通过但需两步验证码的 428 响应。
|
||||||
|
type totpRequiredResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
TotpRequired bool `json:"totpRequired"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// enabledResponse 是布尔开关查询响应。
|
||||||
|
type enabledResponse struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// totpSetupResponse 是 TOTP 初始化响应。
|
||||||
|
type totpSetupResponse struct {
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
OtpauthUri string `json:"otpauthUri"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// credentialsResponse 是当前登录账号信息。
|
||||||
|
type credentialsResponse struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
PasswordLoginDisabled bool `json:"passwordLoginDisabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// oauthProvidersResponse 是外部登录 provider 列表。
|
||||||
|
type oauthProvidersResponse struct {
|
||||||
|
Providers []service.ProviderInfo `json:"providers"`
|
||||||
|
PasswordLoginDisabled bool `json:"passwordLoginDisabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// urlResponse 是跳转地址响应。
|
||||||
|
type urlResponse struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// aboutResponse 是「设置 · 关于」页构建与运行信息。
|
||||||
|
type aboutResponse struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
BuildTime string `json:"buildTime"`
|
||||||
|
GoVersion string `json:"goVersion"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
StartedAt string `json:"startedAt"`
|
||||||
|
UptimeSeconds int64 `json:"uptimeSeconds"`
|
||||||
|
Resources aboutResources `json:"resources"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// aboutResources 是进程资源占用快照。
|
||||||
|
type aboutResources struct {
|
||||||
|
CpuAvgPercent float64 `json:"cpuAvgPercent"`
|
||||||
|
NumCpu int `json:"numCpu"`
|
||||||
|
Goroutines int `json:"goroutines"`
|
||||||
|
MemHeapBytes uint64 `json:"memHeapBytes"`
|
||||||
|
MemSysBytes uint64 `json:"memSysBytes"`
|
||||||
|
DbEngine string `json:"dbEngine"`
|
||||||
|
DbBytes int64 `json:"dbBytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicIpResponse 是换绑公网 IP 结果。
|
||||||
|
type publicIpResponse struct {
|
||||||
|
PublicIp string `json:"publicIp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ipv6AddressResponse 是实例新增 IPv6 结果。
|
||||||
|
type ipv6AddressResponse struct {
|
||||||
|
Ipv6Address string `json:"ipv6Address"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// addressResponse 是 VNIC 新增 IPv6 结果。
|
||||||
|
type addressResponse struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ipv6StepsResponse 是 VCN 开启 IPv6 的分步结果。
|
||||||
|
type ipv6StepsResponse struct {
|
||||||
|
Steps []oci.IPv6Step `json:"steps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// configChangesResponse 是租户配置校验 / 更新结果(config 与字段变更对照)。
|
||||||
|
type configChangesResponse struct {
|
||||||
|
Config *model.OciConfig `json:"config"`
|
||||||
|
Changes service.Changes `json:"changes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// aiKeyCreateResponse 是 AI 密钥创建结果;key 为明文,仅本次返回。
|
||||||
|
type aiKeyCreateResponse struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Item model.AiKey `json:"item"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// usedByResponse 是代理关联租户结果(关联数)。
|
||||||
|
type usedByResponse struct {
|
||||||
|
UsedBy int64 `json:"usedBy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawDetailResponse 是审计事件原文响应。
|
||||||
|
type rawDetailResponse struct {
|
||||||
|
Raw json.RawMessage `json:"raw"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// passwordResponse 是租户用户重置密码结果(一次性明文)。
|
||||||
|
type passwordResponse struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// deletedTotpResponse 是清除 MFA 设备结果。
|
||||||
|
type deletedTotpResponse struct {
|
||||||
|
DeletedTotpDevices int `json:"deletedTotpDevices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// deletedApiKeysResponse 是清理用户 API Key 结果。
|
||||||
|
type deletedApiKeysResponse struct {
|
||||||
|
DeletedApiKeys int `json:"deletedApiKeys"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// createInstancesResponse 是批量创建实例结果;部分成功仍 201,errors 为逐台失败信息。
|
||||||
|
type createInstancesResponse struct {
|
||||||
|
Instances []oci.Instance `json:"instances"`
|
||||||
|
Errors []string `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// logWebhookStatusResponse 是日志回传 webhook 状态。
|
||||||
|
type logWebhookStatusResponse struct {
|
||||||
|
Exists bool `json:"exists"`
|
||||||
|
Webhook *service.LogWebhookInfo `json:"webhook,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// webConsoleSessionResponse 是网页控制台会话创建结果。
|
||||||
|
type webConsoleSessionResponse struct {
|
||||||
|
SessionId string `json:"sessionId"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
_ "oci-portal/internal/model" // swagger 注解引用
|
||||||
"oci-portal/internal/service"
|
"oci-portal/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ type systemLogHandler struct {
|
|||||||
//
|
//
|
||||||
// @Summary 系统操作日志列表
|
// @Summary 系统操作日志列表
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Success 200 {object} map[string]any "items 与 total"
|
// @Success 200 {object} pagedResponse[model.SystemLog] "items 与 total"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/system-logs [get]
|
// @Router /api/v1/system-logs [get]
|
||||||
func (h *systemLogHandler) list(c *gin.Context) {
|
func (h *systemLogHandler) list(c *gin.Context) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
_ "oci-portal/internal/model" // swagger 注解引用
|
||||||
"oci-portal/internal/service"
|
"oci-portal/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ type updateTaskRequest struct {
|
|||||||
// @Summary 创建任务
|
// @Summary 创建任务
|
||||||
// @Tags 任务与日志回传
|
// @Tags 任务与日志回传
|
||||||
// @Param body body createTaskRequest true "任务定义(类型/cron/payload)"
|
// @Param body body createTaskRequest true "任务定义(类型/cron/payload)"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} model.Task
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/tasks [post]
|
// @Router /api/v1/tasks [post]
|
||||||
func (h *taskHandler) create(c *gin.Context) {
|
func (h *taskHandler) create(c *gin.Context) {
|
||||||
@@ -57,7 +58,7 @@ func (h *taskHandler) create(c *gin.Context) {
|
|||||||
|
|
||||||
// @Summary 任务列表
|
// @Summary 任务列表
|
||||||
// @Tags 任务与日志回传
|
// @Tags 任务与日志回传
|
||||||
// @Success 200 {array} map[string]any
|
// @Success 200 {array} model.Task
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/tasks [get]
|
// @Router /api/v1/tasks [get]
|
||||||
func (h *taskHandler) list(c *gin.Context) {
|
func (h *taskHandler) list(c *gin.Context) {
|
||||||
@@ -72,7 +73,7 @@ func (h *taskHandler) list(c *gin.Context) {
|
|||||||
// @Summary 任务详情
|
// @Summary 任务详情
|
||||||
// @Tags 任务与日志回传
|
// @Tags 任务与日志回传
|
||||||
// @Param id path int true "任务 ID"
|
// @Param id path int true "任务 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} model.Task
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/tasks/{id} [get]
|
// @Router /api/v1/tasks/{id} [get]
|
||||||
func (h *taskHandler) get(c *gin.Context) {
|
func (h *taskHandler) get(c *gin.Context) {
|
||||||
@@ -92,7 +93,7 @@ func (h *taskHandler) get(c *gin.Context) {
|
|||||||
// @Tags 任务与日志回传
|
// @Tags 任务与日志回传
|
||||||
// @Param id path int true "任务 ID"
|
// @Param id path int true "任务 ID"
|
||||||
// @Param body body updateTaskRequest true "可更新字段"
|
// @Param body body updateTaskRequest true "可更新字段"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} model.Task
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/tasks/{id} [put]
|
// @Router /api/v1/tasks/{id} [put]
|
||||||
func (h *taskHandler) update(c *gin.Context) {
|
func (h *taskHandler) update(c *gin.Context) {
|
||||||
@@ -139,7 +140,7 @@ func (h *taskHandler) remove(c *gin.Context) {
|
|||||||
// @Summary 任务执行日志
|
// @Summary 任务执行日志
|
||||||
// @Tags 任务与日志回传
|
// @Tags 任务与日志回传
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} model.TaskLog
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/tasks/{id}/logs [get]
|
// @Router /api/v1/tasks/{id}/logs [get]
|
||||||
func (h *taskHandler) logs(c *gin.Context) {
|
func (h *taskHandler) logs(c *gin.Context) {
|
||||||
@@ -159,8 +160,8 @@ func (h *taskHandler) logs(c *gin.Context) {
|
|||||||
// @Summary 立即执行任务(异步触发,结果经任务日志轮询获取)
|
// @Summary 立即执行任务(异步触发,结果经任务日志轮询获取)
|
||||||
// @Tags 任务与日志回传
|
// @Tags 任务与日志回传
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 202 {object} map[string]any
|
// @Success 202 {object} triggerResponse
|
||||||
// @Failure 409 {object} map[string]any "任务正在执行中"
|
// @Failure 409 {object} errorResponse "任务正在执行中"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/tasks/{id}/run [post]
|
// @Router /api/v1/tasks/{id}/run [post]
|
||||||
func (h *taskHandler) run(c *gin.Context) {
|
func (h *taskHandler) run(c *gin.Context) {
|
||||||
|
|||||||
+18
-18
@@ -18,7 +18,7 @@ import (
|
|||||||
// @Tags 计算
|
// @Tags 计算
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.InstanceTraffic
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/traffic [get]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/traffic [get]
|
||||||
func (h *ociConfigHandler) instanceTraffic(c *gin.Context) {
|
func (h *ociConfigHandler) instanceTraffic(c *gin.Context) {
|
||||||
@@ -38,7 +38,7 @@ func (h *ociConfigHandler) instanceTraffic(c *gin.Context) {
|
|||||||
// @Summary 配置成本快照
|
// @Summary 配置成本快照
|
||||||
// @Tags 成本
|
// @Tags 成本
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.CostItem
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/costs [get]
|
// @Router /api/v1/oci-configs/{id}/costs [get]
|
||||||
func (h *ociConfigHandler) costs(c *gin.Context) {
|
func (h *ociConfigHandler) costs(c *gin.Context) {
|
||||||
@@ -75,7 +75,7 @@ func (h *ociConfigHandler) costs(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param cursor query string false "续查游标(上次响应原样带回)"
|
// @Param cursor query string false "续查游标(上次响应原样带回)"
|
||||||
// @Param limit query int false "单批目标条数,缺省 100,上限 200"
|
// @Param limit query int false "单批目标条数,缺省 100,上限 200"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.AuditEventsView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/audit-events [get]
|
// @Router /api/v1/oci-configs/{id}/audit-events [get]
|
||||||
func (h *ociConfigHandler) getAuditEvents(c *gin.Context) {
|
func (h *ociConfigHandler) getAuditEvents(c *gin.Context) {
|
||||||
@@ -103,7 +103,7 @@ func (h *ociConfigHandler) getAuditEvents(c *gin.Context) {
|
|||||||
// @Summary 按 eventId 取回原始事件 JSON:缓存命中秒开,
|
// @Summary 按 eventId 取回原始事件 JSON:缓存命中秒开,
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} rawDetailResponse "raw 为事件原文 JSON"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/audit-events/detail [get]
|
// @Router /api/v1/oci-configs/{id}/audit-events/detail [get]
|
||||||
func (h *ociConfigHandler) getAuditEventDetail(c *gin.Context) {
|
func (h *ociConfigHandler) getAuditEventDetail(c *gin.Context) {
|
||||||
@@ -145,7 +145,7 @@ type createTenantUserRequest struct {
|
|||||||
// @Summary 租户身份域列表
|
// @Summary 租户身份域列表
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.IdentityDomain
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/domains [get]
|
// @Router /api/v1/oci-configs/{id}/domains [get]
|
||||||
func (h *ociConfigHandler) listIdentityDomains(c *gin.Context) {
|
func (h *ociConfigHandler) listIdentityDomains(c *gin.Context) {
|
||||||
@@ -165,7 +165,7 @@ func (h *ociConfigHandler) listIdentityDomains(c *gin.Context) {
|
|||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.TenantUser
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/users [get]
|
// @Router /api/v1/oci-configs/{id}/users [get]
|
||||||
func (h *ociConfigHandler) listTenantUsers(c *gin.Context) {
|
func (h *ociConfigHandler) listTenantUsers(c *gin.Context) {
|
||||||
@@ -186,7 +186,7 @@ func (h *ociConfigHandler) listTenantUsers(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param userId path string true "userId"
|
// @Param userId path string true "userId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.TenantUserDetail
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/users/{userId} [get]
|
// @Router /api/v1/oci-configs/{id}/users/{userId} [get]
|
||||||
func (h *ociConfigHandler) getTenantUserDetail(c *gin.Context) {
|
func (h *ociConfigHandler) getTenantUserDetail(c *gin.Context) {
|
||||||
@@ -207,7 +207,7 @@ func (h *ociConfigHandler) getTenantUserDetail(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param body body createTenantUserRequest true "请求体"
|
// @Param body body createTenantUserRequest true "请求体"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} oci.TenantUser
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/users [post]
|
// @Router /api/v1/oci-configs/{id}/users [post]
|
||||||
func (h *ociConfigHandler) createTenantUser(c *gin.Context) {
|
func (h *ociConfigHandler) createTenantUser(c *gin.Context) {
|
||||||
@@ -253,7 +253,7 @@ type updateTenantUserRequest struct {
|
|||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param userId path string true "userId"
|
// @Param userId path string true "userId"
|
||||||
// @Param body body updateTenantUserRequest true "请求体"
|
// @Param body body updateTenantUserRequest true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.TenantUser
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/users/{userId} [put]
|
// @Router /api/v1/oci-configs/{id}/users/{userId} [put]
|
||||||
func (h *ociConfigHandler) updateTenantUser(c *gin.Context) {
|
func (h *ociConfigHandler) updateTenantUser(c *gin.Context) {
|
||||||
@@ -306,7 +306,7 @@ func (h *ociConfigHandler) deleteTenantUser(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param userId path string true "userId"
|
// @Param userId path string true "userId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} passwordResponse "一次性明文密码"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/users/{userId}/reset-password [post]
|
// @Router /api/v1/oci-configs/{id}/users/{userId}/reset-password [post]
|
||||||
func (h *ociConfigHandler) resetTenantUserPassword(c *gin.Context) {
|
func (h *ociConfigHandler) resetTenantUserPassword(c *gin.Context) {
|
||||||
@@ -327,7 +327,7 @@ func (h *ociConfigHandler) resetTenantUserPassword(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param userId path string true "userId"
|
// @Param userId path string true "userId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} deletedTotpResponse
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/users/{userId}/mfa-devices [delete]
|
// @Router /api/v1/oci-configs/{id}/users/{userId}/mfa-devices [delete]
|
||||||
func (h *ociConfigHandler) deleteTenantUserMfa(c *gin.Context) {
|
func (h *ociConfigHandler) deleteTenantUserMfa(c *gin.Context) {
|
||||||
@@ -347,7 +347,7 @@ func (h *ociConfigHandler) deleteTenantUserMfa(c *gin.Context) {
|
|||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param userId path string true "userId"
|
// @Param userId path string true "userId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} deletedApiKeysResponse
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/users/{userId}/api-keys [delete]
|
// @Router /api/v1/oci-configs/{id}/users/{userId}/api-keys [delete]
|
||||||
func (h *ociConfigHandler) deleteTenantUserApiKeys(c *gin.Context) {
|
func (h *ociConfigHandler) deleteTenantUserApiKeys(c *gin.Context) {
|
||||||
@@ -370,7 +370,7 @@ func (h *ociConfigHandler) deleteTenantUserApiKeys(c *gin.Context) {
|
|||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.NotificationRecipients
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/notification-recipients [get]
|
// @Router /api/v1/oci-configs/{id}/notification-recipients [get]
|
||||||
func (h *ociConfigHandler) getNotificationRecipients(c *gin.Context) {
|
func (h *ociConfigHandler) getNotificationRecipients(c *gin.Context) {
|
||||||
@@ -391,7 +391,7 @@ func (h *ociConfigHandler) getNotificationRecipients(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param body body object true "请求体(见接口说明)"
|
// @Param body body object true "请求体(见接口说明)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.NotificationRecipients
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/notification-recipients [put]
|
// @Router /api/v1/oci-configs/{id}/notification-recipients [put]
|
||||||
func (h *ociConfigHandler) updateNotificationRecipients(c *gin.Context) {
|
func (h *ociConfigHandler) updateNotificationRecipients(c *gin.Context) {
|
||||||
@@ -418,7 +418,7 @@ func (h *ociConfigHandler) updateNotificationRecipients(c *gin.Context) {
|
|||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.PasswordPolicyInfo
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/password-policies [get]
|
// @Router /api/v1/oci-configs/{id}/password-policies [get]
|
||||||
func (h *ociConfigHandler) listPasswordPolicies(c *gin.Context) {
|
func (h *ociConfigHandler) listPasswordPolicies(c *gin.Context) {
|
||||||
@@ -440,7 +440,7 @@ func (h *ociConfigHandler) listPasswordPolicies(c *gin.Context) {
|
|||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param policyId path string true "policyId"
|
// @Param policyId path string true "policyId"
|
||||||
// @Param body body object true "请求体(见接口说明)"
|
// @Param body body object true "请求体(见接口说明)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.PasswordPolicyInfo
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/password-policies/{policyId} [put]
|
// @Router /api/v1/oci-configs/{id}/password-policies/{policyId} [put]
|
||||||
func (h *ociConfigHandler) updatePasswordPolicy(c *gin.Context) {
|
func (h *ociConfigHandler) updatePasswordPolicy(c *gin.Context) {
|
||||||
@@ -473,7 +473,7 @@ func (h *ociConfigHandler) updatePasswordPolicy(c *gin.Context) {
|
|||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.IdentitySettingInfo
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/identity-settings [get]
|
// @Router /api/v1/oci-configs/{id}/identity-settings [get]
|
||||||
func (h *ociConfigHandler) getIdentitySetting(c *gin.Context) {
|
func (h *ociConfigHandler) getIdentitySetting(c *gin.Context) {
|
||||||
@@ -494,7 +494,7 @@ func (h *ociConfigHandler) getIdentitySetting(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param body body object true "请求体(见接口说明)"
|
// @Param body body object true "请求体(见接口说明)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.IdentitySettingInfo
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/identity-settings [put]
|
// @Router /api/v1/oci-configs/{id}/identity-settings [put]
|
||||||
func (h *ociConfigHandler) updateIdentitySetting(c *gin.Context) {
|
func (h *ociConfigHandler) updateIdentitySetting(c *gin.Context) {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ type createConsoleSessionRequest struct {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "实例 OCID"
|
// @Param instanceId path string true "实例 OCID"
|
||||||
// @Param body body object true "{type: serial|vnc}"
|
// @Param body body object true "{type: serial|vnc}"
|
||||||
// @Success 200 {object} map[string]any "sessionId 与 wsPath"
|
// @Success 201 {object} webConsoleSessionResponse "sessionId 与 type"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/console-sessions [post]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/console-sessions [post]
|
||||||
func (h *consoleHandler) create(c *gin.Context) {
|
func (h *consoleHandler) create(c *gin.Context) {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ type webhookHandler struct {
|
|||||||
// @Param secret path string true "面板生成的回传密钥(鉴权凭据)"
|
// @Param secret path string true "面板生成的回传密钥(鉴权凭据)"
|
||||||
// @Param body body object true "ONS 消息原文(SubscriptionConfirmation / Notification)"
|
// @Param body body object true "ONS 消息原文(SubscriptionConfirmation / Notification)"
|
||||||
// @Success 200 "已受理"
|
// @Success 200 "已受理"
|
||||||
// @Failure 403 {object} map[string]string "时间戳超窗或证书源非 Oracle 域"
|
// @Failure 403 {object} errorResponse "时间戳超窗或证书源非 Oracle 域"
|
||||||
// @Failure 404 "secret 无效(不暴露端点存在性)"
|
// @Failure 404 "secret 无效(不暴露端点存在性)"
|
||||||
// @Router /api/v1/webhooks/oci-logs/{secret} [post]
|
// @Router /api/v1/webhooks/oci-logs/{secret} [post]
|
||||||
func (h *webhookHandler) handle(c *gin.Context) {
|
func (h *webhookHandler) handle(c *gin.Context) {
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ type AiChannel struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AiModelCache 是渠道区域的可用模型缓存(整渠道覆盖式同步)。
|
// AiModelCache 是渠道区域的可用模型缓存(整渠道覆盖式同步)。
|
||||||
// Capability 为 CHAT / EMBEDDING;存量空串视为 CHAT(加列前只同步对话模型)。
|
// Capability 为 CHAT / EMBEDDING / RERANK / TTS;存量空串视为 CHAT(加列前只同步对话模型)。
|
||||||
type AiModelCache struct {
|
type AiModelCache struct {
|
||||||
ID uint `gorm:"primaryKey" json:"-"`
|
ID uint `gorm:"primaryKey" json:"-"`
|
||||||
ChannelID uint `gorm:"index" json:"channelId"`
|
ChannelID uint `gorm:"index" json:"channelId"`
|
||||||
|
|||||||
@@ -106,6 +106,12 @@ type Client interface {
|
|||||||
GenAiCompatResponses(ctx context.Context, cred Credentials, region string, body []byte) ([]byte, error)
|
GenAiCompatResponses(ctx context.Context, cred Credentials, region string, body []byte) ([]byte, error)
|
||||||
// GenAiCompatResponsesStream 流式直通 /actions/v1/responses,建立成功返回 SSE body。
|
// GenAiCompatResponsesStream 流式直通 /actions/v1/responses,建立成功返回 SSE body。
|
||||||
GenAiCompatResponsesStream(ctx context.Context, cred Credentials, region string, body []byte) (io.ReadCloser, error)
|
GenAiCompatResponsesStream(ctx context.Context, cred Credentials, region string, body []byte) (io.ReadCloser, error)
|
||||||
|
// GenAiCompatSpeech 直通 OpenAI Audio Speech 请求体到 /openai/v1/audio/speech,返回音频与 Content-Type。
|
||||||
|
GenAiCompatSpeech(ctx context.Context, cred Credentials, region string, body []byte) ([]byte, string, error)
|
||||||
|
// GenAiRerank 文档重排,返回按相关度排序的下标与得分。
|
||||||
|
GenAiRerank(ctx context.Context, cred Credentials, region, modelOcid, query string, documents []string, topN *int) ([]RerankRank, error)
|
||||||
|
// GenAiApplyGuardrails 对单条文本执行内容审核 / PII / 提示注入检测。
|
||||||
|
GenAiApplyGuardrails(ctx context.Context, cred Credentials, region, text string) (*GuardrailsOutcome, error)
|
||||||
// 控制台连接:创建(VNC/串口连接串)、列出、删除。
|
// 控制台连接:创建(VNC/串口连接串)、列出、删除。
|
||||||
CreateConsoleConnection(ctx context.Context, cred Credentials, region, instanceID, sshPublicKey string) (ConsoleConnection, error)
|
CreateConsoleConnection(ctx context.Context, cred Credentials, region, instanceID, sshPublicKey string) (ConsoleConnection, error)
|
||||||
ListConsoleConnections(ctx context.Context, cred Credentials, region, instanceID string) ([]ConsoleConnection, error)
|
ListConsoleConnections(ctx context.Context, cred Credentials, region, instanceID string) ([]ConsoleConnection, error)
|
||||||
|
|||||||
@@ -131,6 +131,11 @@ func modelCapability(m generativeai.ModelSummary) string {
|
|||||||
return "CHAT"
|
return "CHAT"
|
||||||
case generativeai.ModelCapabilityTextEmbeddings:
|
case generativeai.ModelCapabilityTextEmbeddings:
|
||||||
capability = "EMBEDDING"
|
capability = "EMBEDDING"
|
||||||
|
case generativeai.ModelCapabilityTextRerank:
|
||||||
|
capability = "RERANK"
|
||||||
|
case generativeai.ModelCapabilityEnum("TEXT_TO_AUDIO"):
|
||||||
|
// SDK v65.120 尚无该枚举常量,按原始字符串匹配(xai.grok-tts)
|
||||||
|
capability = "TTS"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return capability
|
return capability
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RerankRank 是重排结果的一项:index 指向入参 documents 下标。
|
||||||
|
type RerankRank struct {
|
||||||
|
Index int
|
||||||
|
Score float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// GuardrailCategory 是内容审核分类得分(OVERALL / BLOCKLIST)。
|
||||||
|
type GuardrailCategory struct {
|
||||||
|
Name string
|
||||||
|
Score float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// GuardrailPiiHit 是一处 PII 命中(片段原文与位置)。
|
||||||
|
type GuardrailPiiHit struct {
|
||||||
|
Text string
|
||||||
|
Label string
|
||||||
|
Score float64
|
||||||
|
Offset int
|
||||||
|
Length int
|
||||||
|
}
|
||||||
|
|
||||||
|
// GuardrailsOutcome 汇总 ApplyGuardrails 三能力结果。
|
||||||
|
type GuardrailsOutcome struct {
|
||||||
|
Categories []GuardrailCategory
|
||||||
|
Pii []GuardrailPiiHit
|
||||||
|
PromptInjectionScore *float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenAiRerank 实现 Client:文档重排(on-demand serving),返回按相关度排序的下标与得分。
|
||||||
|
func (c *RealClient) GenAiRerank(ctx context.Context, cred Credentials, region, modelOcid, query string, documents []string, topN *int) ([]RerankRank, error) {
|
||||||
|
ic, err := c.genAiInferenceClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := ic.RerankText(ctx, generativeaiinference.RerankTextRequest{
|
||||||
|
RerankTextDetails: generativeaiinference.RerankTextDetails{
|
||||||
|
CompartmentId: &cred.TenancyOCID,
|
||||||
|
ServingMode: generativeaiinference.OnDemandServingMode{ModelId: &modelOcid},
|
||||||
|
Input: &query,
|
||||||
|
Documents: documents,
|
||||||
|
TopN: topN,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("genai rerank: %w", err)
|
||||||
|
}
|
||||||
|
ranks := make([]RerankRank, 0, len(resp.DocumentRanks))
|
||||||
|
for _, r := range resp.DocumentRanks {
|
||||||
|
if r.Index == nil || r.RelevanceScore == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ranks = append(ranks, RerankRank{Index: *r.Index, Score: *r.RelevanceScore})
|
||||||
|
}
|
||||||
|
return ranks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenAiApplyGuardrails 实现 Client:对单条文本执行内容审核 / PII / 提示注入三检测。
|
||||||
|
func (c *RealClient) GenAiApplyGuardrails(ctx context.Context, cred Credentials, region, text string) (*GuardrailsOutcome, error) {
|
||||||
|
ic, err := c.genAiInferenceClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := ic.ApplyGuardrails(ctx, generativeaiinference.ApplyGuardrailsRequest{
|
||||||
|
ApplyGuardrailsDetails: generativeaiinference.ApplyGuardrailsDetails{
|
||||||
|
CompartmentId: &cred.TenancyOCID,
|
||||||
|
Input: generativeaiinference.GuardrailsTextInput{Content: common.String(text)},
|
||||||
|
GuardrailConfigs: &generativeaiinference.GuardrailConfigs{
|
||||||
|
ContentModerationConfig: &generativeaiinference.ContentModerationConfiguration{Categories: []string{"OVERALL"}},
|
||||||
|
PersonallyIdentifiableInformationConfig: &generativeaiinference.PersonallyIdentifiableInformationConfiguration{Types: []string{}},
|
||||||
|
PromptInjectionConfig: &generativeaiinference.PromptInjectionConfiguration{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("genai guardrails: %w", err)
|
||||||
|
}
|
||||||
|
return guardrailsOutcome(resp.Results), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardrailsOutcome 把 SDK 结果换算为 IR;空段置空切片,得分缺失跳过。
|
||||||
|
func guardrailsOutcome(r *generativeaiinference.GuardrailsResults) *GuardrailsOutcome {
|
||||||
|
out := &GuardrailsOutcome{}
|
||||||
|
if r == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if r.ContentModeration != nil {
|
||||||
|
for _, cat := range r.ContentModeration.Categories {
|
||||||
|
if cat.Name == nil || cat.Score == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Categories = append(out.Categories, GuardrailCategory{Name: *cat.Name, Score: *cat.Score})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, hit := range r.PersonallyIdentifiableInformation {
|
||||||
|
h := GuardrailPiiHit{}
|
||||||
|
if hit.Text != nil {
|
||||||
|
h.Text = *hit.Text
|
||||||
|
}
|
||||||
|
if hit.Label != nil {
|
||||||
|
h.Label = *hit.Label
|
||||||
|
}
|
||||||
|
if hit.Score != nil {
|
||||||
|
h.Score = *hit.Score
|
||||||
|
}
|
||||||
|
if hit.Offset != nil {
|
||||||
|
h.Offset = *hit.Offset
|
||||||
|
}
|
||||||
|
if hit.Length != nil {
|
||||||
|
h.Length = *hit.Length
|
||||||
|
}
|
||||||
|
out.Pii = append(out.Pii, h)
|
||||||
|
}
|
||||||
|
if r.PromptInjection != nil && r.PromptInjection.Score != nil {
|
||||||
|
out.PromptInjectionScore = r.PromptInjection.Score
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -14,9 +14,9 @@ import (
|
|||||||
const compatResponsesLimit = int64(8 << 20)
|
const compatResponsesLimit = int64(8 << 20)
|
||||||
|
|
||||||
// GenAiCompatResponses 实现 Client:把 OpenAI Responses 请求体直通到 OCI
|
// GenAiCompatResponses 实现 Client:把 OpenAI Responses 请求体直通到 OCI
|
||||||
// `/20231130/actions/v1/responses`(IAM 签名)。该端点实测可执行 xAI 服务端工具
|
// `/20231130/actions/v1/responses`(IAM 签名)。xAI 服务端工具(web_search /
|
||||||
// (web_search / x_search),但不在 Oracle 文档化工具白名单内,行为可能随服务
|
// x_search / code_interpreter)与 mcp 已被 Oracle 文档正式支持,工具参数与限制
|
||||||
// 版本、模型或区域变化;调用方须自行校验并改写请求体(store/stream)。
|
// 遵循 xAI 规格;调用方须自行校验并改写请求体(store/stream)。
|
||||||
func (c *RealClient) GenAiCompatResponses(ctx context.Context, cred Credentials, region string, body []byte) ([]byte, error) {
|
func (c *RealClient) GenAiCompatResponses(ctx context.Context, cred Credentials, region string, body []byte) ([]byte, error) {
|
||||||
ic, err := c.genAiInferenceClient(cred, region)
|
ic, err := c.genAiInferenceClient(cred, region)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// compatSpeechLimit 限制 TTS 音频响应体大小(长文本 mp3 给足余量)。
|
||||||
|
const compatSpeechLimit = int64(64 << 20)
|
||||||
|
|
||||||
|
// GenAiCompatSpeech 实现 Client:把 OpenAI Audio Speech 请求体直通到 OCI
|
||||||
|
// 兼容面 `/openai/v1/audio/speech`(IAM 签名,BasePath 置空——该面与
|
||||||
|
// /20231130/actions 面并存,实测仅前者承载 TTS)。返回音频字节与 Content-Type。
|
||||||
|
func (c *RealClient) GenAiCompatSpeech(ctx context.Context, cred Credentials, region string, body []byte) ([]byte, string, error) {
|
||||||
|
ic, err := c.genAiInferenceClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
client := ic.BaseClient
|
||||||
|
common.UpdateEndpointTemplateForOptions(&client)
|
||||||
|
common.SetMissingTemplateParams(&client)
|
||||||
|
client.BasePath = ""
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "/openai/v1/audio/speech", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("build compat speech request: %w", err)
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("opc-compartment-id", cred.TenancyOCID)
|
||||||
|
response, err := client.Call(ctx, request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
payload, err := io.ReadAll(io.LimitReader(response.Body, compatSpeechLimit))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("read compat speech body: %w", err)
|
||||||
|
}
|
||||||
|
return payload, response.Header.Get("Content-Type"), nil
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -58,11 +59,39 @@ type AiGatewayService struct {
|
|||||||
lastTouch map[uint]time.Time
|
lastTouch map[uint]time.Time
|
||||||
// onChannelsChanged 在渠道增删后触发,由 main 装配为探测任务同步钩子
|
// onChannelsChanged 在渠道增删后触发,由 main 装配为探测任务同步钩子
|
||||||
onChannelsChanged func(context.Context)
|
onChannelsChanged func(context.Context)
|
||||||
|
// filterDeprecated 是「过滤弃用模型」开关(内存镜像,持久化在 settings 表)
|
||||||
|
filterDeprecated 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 {
|
||||||
return &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
|
||||||
|
if err := db.Where("key = ?", settingAiFilterDeprecated).First(&row).Error; err == nil {
|
||||||
|
s.filterDeprecated.Store(row.Value == "1")
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// settingAiFilterDeprecated 是「过滤弃用模型」开关的配置键,值 "1"/"0",缺省关闭。
|
||||||
|
const settingAiFilterDeprecated = "ai_filter_deprecated"
|
||||||
|
|
||||||
|
// FilterDeprecated 返回「过滤弃用模型」开关状态。
|
||||||
|
func (s *AiGatewayService) FilterDeprecated() bool { return s.filterDeprecated.Load() }
|
||||||
|
|
||||||
|
// SetFilterDeprecated 持久化并即时生效开关:开启后已宣布弃用
|
||||||
|
// (deprecated_at 非空,即使未退役)的模型从列表与路由中排除。
|
||||||
|
func (s *AiGatewayService) SetFilterDeprecated(ctx context.Context, on bool) error {
|
||||||
|
value := "0"
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
s.filterDeprecated.Store(on)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetOnChannelsChanged 注册渠道数量变化钩子(渠道创建/删除成功后调用)。
|
// SetOnChannelsChanged 注册渠道数量变化钩子(渠道创建/删除成功后调用)。
|
||||||
@@ -511,6 +540,9 @@ func (s *AiGatewayService) GatewayModels(ctx context.Context, group string) (aiw
|
|||||||
if group != "" {
|
if group != "" {
|
||||||
q = q.Where("ai_channels.channel_group = ?", group)
|
q = q.Where("ai_channels.channel_group = ?", group)
|
||||||
}
|
}
|
||||||
|
if s.FilterDeprecated() {
|
||||||
|
q = q.Where("ai_model_caches.deprecated_at IS NULL")
|
||||||
|
}
|
||||||
var rows []model.AiModelCache
|
var rows []model.AiModelCache
|
||||||
err := q.Order("ai_model_caches.name ASC").Find(&rows).Error
|
err := q.Order("ai_model_caches.name ASC").Find(&rows).Error
|
||||||
list := aiwire.ModelList{Object: "list", Data: []aiwire.Model{}}
|
list := aiwire.ModelList{Object: "list", Data: []aiwire.Model{}}
|
||||||
|
|||||||
@@ -164,6 +164,9 @@ func (s *AiGatewayService) modelChannels(ctx context.Context, modelName, capabil
|
|||||||
} else {
|
} else {
|
||||||
q = q.Where("capability = ?", capability)
|
q = q.Where("capability = ?", capability)
|
||||||
}
|
}
|
||||||
|
if s.FilterDeprecated() {
|
||||||
|
q = q.Where("deprecated_at IS NULL")
|
||||||
|
}
|
||||||
var rows []model.AiModelCache
|
var rows []model.AiModelCache
|
||||||
if err := q.Find(&rows).Error; err != nil {
|
if err := q.Find(&rows).Error; err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"oci-portal/internal/aiwire"
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
)
|
||||||
|
|
||||||
|
// moderationsMaxInputs 限制单次审核条数,防止批量滥用拖垮渠道配额。
|
||||||
|
const moderationsMaxInputs = 8
|
||||||
|
|
||||||
|
// SpeechBodyNormalize 校验并规范化 TTS 请求体:model / input 必填;
|
||||||
|
// xAI 上游把 language 当必填(实测缺失返回 422),缺省注入 "auto"。
|
||||||
|
// 其余字段(voice / response_format / extra_body 平铺项)原样保留。
|
||||||
|
func SpeechBodyNormalize(raw []byte) (string, []byte, error) {
|
||||||
|
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||||
|
dec.UseNumber()
|
||||||
|
var body map[string]any
|
||||||
|
if err := dec.Decode(&body); err != nil {
|
||||||
|
return "", nil, fmt.Errorf("解析请求体: %w", err)
|
||||||
|
}
|
||||||
|
modelName, _ := body["model"].(string)
|
||||||
|
input, _ := body["input"].(string)
|
||||||
|
if strings.TrimSpace(modelName) == "" || strings.TrimSpace(input) == "" {
|
||||||
|
return "", nil, fmt.Errorf("model 与 input 不能为空")
|
||||||
|
}
|
||||||
|
if lang, ok := body["language"].(string); !ok || strings.TrimSpace(lang) == "" {
|
||||||
|
body["language"] = "auto"
|
||||||
|
}
|
||||||
|
out, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("重组请求体: %w", err)
|
||||||
|
}
|
||||||
|
return modelName, out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ttsDefaultModel 是 /ai/v1/tts 缺省注入的模型(xAI 官方格式无 model 字段)。
|
||||||
|
const ttsDefaultModel = "xai.grok-tts"
|
||||||
|
|
||||||
|
// TtsBodyConvert 把 xAI 官方 TTS 格式转换为上游 OpenAI 兼容 audio/speech 形态:
|
||||||
|
// text→input、voice_id→voice,text 与 language 必填(对齐 xAI 官方);
|
||||||
|
// model 为网关扩展字段,缺省注入 ttsDefaultModel;其余字段原样保留。
|
||||||
|
func TtsBodyConvert(raw []byte) (string, []byte, error) {
|
||||||
|
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||||
|
dec.UseNumber()
|
||||||
|
var body map[string]any
|
||||||
|
if err := dec.Decode(&body); err != nil {
|
||||||
|
return "", nil, fmt.Errorf("解析请求体: %w", err)
|
||||||
|
}
|
||||||
|
text, _ := body["text"].(string)
|
||||||
|
language, _ := body["language"].(string)
|
||||||
|
if strings.TrimSpace(text) == "" || strings.TrimSpace(language) == "" {
|
||||||
|
return "", nil, fmt.Errorf("text 与 language 不能为空")
|
||||||
|
}
|
||||||
|
modelName, _ := body["model"].(string)
|
||||||
|
if strings.TrimSpace(modelName) == "" {
|
||||||
|
modelName = ttsDefaultModel
|
||||||
|
body["model"] = modelName
|
||||||
|
}
|
||||||
|
delete(body, "text")
|
||||||
|
body["input"] = text
|
||||||
|
if voice, ok := body["voice_id"].(string); ok && strings.TrimSpace(voice) != "" {
|
||||||
|
body["voice"] = voice
|
||||||
|
}
|
||||||
|
delete(body, "voice_id")
|
||||||
|
out, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("重组请求体: %w", err)
|
||||||
|
}
|
||||||
|
return modelName, out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speech 编排 TTS 调用:按 TTS 能力选渠道,可重试错误换渠道(上限 3 次)。
|
||||||
|
func (s *AiGatewayService) Speech(ctx context.Context, modelName string, body []byte, group string) ([]byte, string, ChatMeta, error) {
|
||||||
|
meta := ChatMeta{}
|
||||||
|
excluded := map[uint]bool{}
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < 3; attempt++ {
|
||||||
|
cand, err := s.pick(ctx, modelName, group, "TTS", excluded)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", meta, firstErr(lastErr, err)
|
||||||
|
}
|
||||||
|
meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name
|
||||||
|
cred, err := s.configs.credentialsByID(ctx, cand.ch.OciConfigID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", meta, err
|
||||||
|
}
|
||||||
|
audio, contentType, err := s.client.GenAiCompatSpeech(ctx, cred, cand.ch.Region, body)
|
||||||
|
if err == nil {
|
||||||
|
s.markSuccess(ctx, cand.ch.ID)
|
||||||
|
return audio, contentType, meta, nil
|
||||||
|
}
|
||||||
|
if done := s.retryStep(ctx, cand.ch.ID, err, excluded, &meta, &lastErr); done {
|
||||||
|
return nil, "", meta, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, "", meta, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rerank 编排文档重排:按 RERANK 能力选渠道,装配 Jina 风格响应。
|
||||||
|
func (s *AiGatewayService) Rerank(ctx context.Context, req aiwire.RerankRequest, group string) (*aiwire.RerankResponse, ChatMeta, error) {
|
||||||
|
meta := ChatMeta{}
|
||||||
|
excluded := map[uint]bool{}
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < 3; attempt++ {
|
||||||
|
cand, err := s.pick(ctx, req.Model, group, "RERANK", excluded)
|
||||||
|
if err != nil {
|
||||||
|
return nil, meta, firstErr(lastErr, err)
|
||||||
|
}
|
||||||
|
meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name
|
||||||
|
cred, err := s.configs.credentialsByID(ctx, cand.ch.OciConfigID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, meta, err
|
||||||
|
}
|
||||||
|
ranks, err := s.client.GenAiRerank(ctx, cred, cand.ch.Region, cand.modelOcid, req.Query, req.Documents, req.TopN)
|
||||||
|
if err == nil {
|
||||||
|
s.markSuccess(ctx, cand.ch.ID)
|
||||||
|
return rerankResponse(req, ranks), meta, nil
|
||||||
|
}
|
||||||
|
if done := s.retryStep(ctx, cand.ch.ID, err, excluded, &meta, &lastErr); done {
|
||||||
|
return nil, meta, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, meta, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// retryStep 统一处理换渠道重试:不可重试返回 true 终止,可重试记罚并排除渠道。
|
||||||
|
func (s *AiGatewayService) retryStep(ctx context.Context, chID uint, err error, excluded map[uint]bool, meta *ChatMeta, lastErr *error) bool {
|
||||||
|
retry, penalize := switchable(err)
|
||||||
|
if !retry {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if penalize {
|
||||||
|
s.markFailure(ctx, chID)
|
||||||
|
}
|
||||||
|
excluded[chID] = true
|
||||||
|
meta.Retries++
|
||||||
|
*lastErr = err
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// rerankResponse 把上游排序结果装配为对外响应;return_documents 时回填原文。
|
||||||
|
func rerankResponse(req aiwire.RerankRequest, ranks []oci.RerankRank) *aiwire.RerankResponse {
|
||||||
|
out := &aiwire.RerankResponse{Model: req.Model, Results: make([]aiwire.RerankResult, 0, len(ranks))}
|
||||||
|
withDoc := req.ReturnDocuments != nil && *req.ReturnDocuments
|
||||||
|
for _, r := range ranks {
|
||||||
|
if r.Index < 0 || r.Index >= len(req.Documents) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item := aiwire.RerankResult{Index: r.Index, RelevanceScore: r.Score}
|
||||||
|
if withDoc {
|
||||||
|
item.Document = &aiwire.RerankDocument{Text: req.Documents[r.Index]}
|
||||||
|
}
|
||||||
|
out.Results = append(out.Results, item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModerationInputs 解析 moderations 的 input 字段:string 或 []string,
|
||||||
|
// 条数与空值校验(上限 moderationsMaxInputs)。
|
||||||
|
func ModerationInputs(raw json.RawMessage) ([]string, error) {
|
||||||
|
var single string
|
||||||
|
if err := json.Unmarshal(raw, &single); err == nil {
|
||||||
|
if strings.TrimSpace(single) == "" {
|
||||||
|
return nil, fmt.Errorf("input 不能为空")
|
||||||
|
}
|
||||||
|
return []string{single}, nil
|
||||||
|
}
|
||||||
|
var many []string
|
||||||
|
if err := json.Unmarshal(raw, &many); err != nil {
|
||||||
|
return nil, fmt.Errorf("input 需为字符串或字符串数组")
|
||||||
|
}
|
||||||
|
if len(many) == 0 || len(many) > moderationsMaxInputs {
|
||||||
|
return nil, fmt.Errorf("input 条数需在 1~%d 之间", moderationsMaxInputs)
|
||||||
|
}
|
||||||
|
for _, item := range many {
|
||||||
|
if strings.TrimSpace(item) == "" {
|
||||||
|
return nil, fmt.Errorf("input 含空条目")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return many, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moderations 编排内容审核:guardrails 为服务级 API 无模型维度,
|
||||||
|
// 按分组选启用渠道(整批同渠道),可重试错误换渠道(上限 3 次)。
|
||||||
|
func (s *AiGatewayService) Moderations(ctx context.Context, id string, inputs []string, group string) (*aiwire.ModerationsResponse, ChatMeta, error) {
|
||||||
|
meta := ChatMeta{}
|
||||||
|
excluded := map[uint]bool{}
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < 3; attempt++ {
|
||||||
|
ch, err := s.pickGuardChannel(ctx, group, excluded)
|
||||||
|
if err != nil {
|
||||||
|
return nil, meta, firstErr(lastErr, err)
|
||||||
|
}
|
||||||
|
meta.ChannelID, meta.ChannelName = ch.ID, ch.Name
|
||||||
|
resp, err := s.moderateOnce(ctx, ch, id, inputs)
|
||||||
|
if err == nil {
|
||||||
|
s.markSuccess(ctx, ch.ID)
|
||||||
|
return resp, meta, nil
|
||||||
|
}
|
||||||
|
if done := s.retryStep(ctx, ch.ID, err, excluded, &meta, &lastErr); done {
|
||||||
|
return nil, meta, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, meta, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickGuardChannel 按分组挑一个启用且未熔断的渠道(权重加成,不查模型缓存)。
|
||||||
|
func (s *AiGatewayService) pickGuardChannel(ctx context.Context, group string, excluded map[uint]bool) (model.AiChannel, error) {
|
||||||
|
q := s.db.WithContext(ctx).Where("enabled = ?", true)
|
||||||
|
if group != "" {
|
||||||
|
q = q.Where("channel_group = ?", group)
|
||||||
|
}
|
||||||
|
var channels []model.AiChannel
|
||||||
|
if err := q.Find(&channels).Error; err != nil {
|
||||||
|
return model.AiChannel{}, err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
avail := channels[:0]
|
||||||
|
for _, ch := range channels {
|
||||||
|
if excluded[ch.ID] || (ch.DisabledUntil != nil && ch.DisabledUntil.After(now)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
avail = append(avail, ch)
|
||||||
|
}
|
||||||
|
if len(avail) == 0 {
|
||||||
|
return model.AiChannel{}, ErrAiNoChannel
|
||||||
|
}
|
||||||
|
return weightedPick(topPriority(avail)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// moderateOnce 在单渠道上逐条审核并装配 OpenAI moderations 外壳。
|
||||||
|
func (s *AiGatewayService) moderateOnce(ctx context.Context, ch model.AiChannel, id string, inputs []string) (*aiwire.ModerationsResponse, error) {
|
||||||
|
cred, err := s.configs.credentialsByID(ctx, ch.OciConfigID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := &aiwire.ModerationsResponse{ID: id, Model: "oci-guardrails",
|
||||||
|
Results: make([]aiwire.ModerationResult, 0, len(inputs))}
|
||||||
|
for _, text := range inputs {
|
||||||
|
outcome, err := s.client.GenAiApplyGuardrails(ctx, cred, ch.Region, text)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out.Results = append(out.Results, moderationResult(outcome))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// moderationFlagThreshold 是判定违规的得分阈值(上游 CM / PI 为二值得分)。
|
||||||
|
const moderationFlagThreshold = 0.5
|
||||||
|
|
||||||
|
// moderationResult 把 guardrails 结果映射为 OpenAI moderations 条目:
|
||||||
|
// flagged 由内容审核与提示注入判定,PII 命中只作扩展信息不参与 flagged。
|
||||||
|
func moderationResult(outcome *oci.GuardrailsOutcome) aiwire.ModerationResult {
|
||||||
|
res := aiwire.ModerationResult{Categories: map[string]bool{}, CategoryScores: map[string]float64{}}
|
||||||
|
for _, cat := range outcome.Categories {
|
||||||
|
key := strings.ToLower(cat.Name)
|
||||||
|
res.Categories[key] = cat.Score >= moderationFlagThreshold
|
||||||
|
res.CategoryScores[key] = cat.Score
|
||||||
|
if res.Categories[key] {
|
||||||
|
res.Flagged = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if outcome.PromptInjectionScore != nil {
|
||||||
|
hit := *outcome.PromptInjectionScore >= moderationFlagThreshold
|
||||||
|
res.Categories["prompt_injection"] = hit
|
||||||
|
res.CategoryScores["prompt_injection"] = *outcome.PromptInjectionScore
|
||||||
|
if hit {
|
||||||
|
res.Flagged = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range outcome.Pii {
|
||||||
|
res.Pii = append(res.Pii, aiwire.ModerationPii{Text: p.Text, Label: p.Label,
|
||||||
|
Score: p.Score, Offset: p.Offset, Length: p.Length})
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"oci-portal/internal/aiwire"
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSpeechBodyNormalize 断言 TTS 请求体校验与 language 缺省注入。
|
||||||
|
func TestSpeechBodyNormalize(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
wantErr bool
|
||||||
|
wantLang string
|
||||||
|
}{
|
||||||
|
{"缺 model 拒绝", `{"input":"你好"}`, true, ""},
|
||||||
|
{"缺 input 拒绝", `{"model":"xai.grok-tts"}`, true, ""},
|
||||||
|
{"language 缺省注入 auto", `{"model":"xai.grok-tts","input":"你好","voice":"ara"}`, false, "auto"},
|
||||||
|
{"language 已有保留", `{"model":"xai.grok-tts","input":"你好","language":"zh"}`, false, "zh"},
|
||||||
|
{"非 JSON 拒绝", `<html>`, true, ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
modelName, body, err := SpeechBodyNormalize([]byte(tt.raw))
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("err = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var out map[string]any
|
||||||
|
_ = json.Unmarshal(body, &out)
|
||||||
|
if modelName != "xai.grok-tts" || out["language"] != tt.wantLang {
|
||||||
|
t.Fatalf("model=%s language=%v, want %s", modelName, out["language"], tt.wantLang)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTtsBodyConvert 断言 xAI 官方 TTS 格式到 OpenAI 兼容形态的转换。
|
||||||
|
func TestTtsBodyConvert(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
wantErr bool
|
||||||
|
wantModel string
|
||||||
|
}{
|
||||||
|
{"缺 text 拒绝", `{"language":"zh"}`, true, ""},
|
||||||
|
{"缺 language 拒绝", `{"text":"你好"}`, true, ""},
|
||||||
|
{"缺省注入默认模型", `{"text":"你好","language":"zh"}`, false, "xai.grok-tts"},
|
||||||
|
{"model 扩展字段可覆盖", `{"model":"xai.other-tts","text":"你好","language":"auto"}`, false, "xai.other-tts"},
|
||||||
|
{"非 JSON 拒绝", `<html>`, true, ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
modelName, _, err := TtsBodyConvert([]byte(tt.raw))
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("err = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if modelName != tt.wantModel {
|
||||||
|
t.Fatalf("model = %s, want %s", modelName, tt.wantModel)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTtsBodyConvertMapping 断言字段映射与未知字段保留。
|
||||||
|
func TestTtsBodyConvertMapping(t *testing.T) {
|
||||||
|
raw := `{"text":"你好","language":"zh","voice_id":"ara","speed":1.2,` +
|
||||||
|
`"output_format":{"codec":"mp3","sample_rate":44100}}`
|
||||||
|
_, body, err := TtsBodyConvert([]byte(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err = %v", err)
|
||||||
|
}
|
||||||
|
var out map[string]any
|
||||||
|
_ = json.Unmarshal(body, &out)
|
||||||
|
if out["input"] != "你好" || out["voice"] != "ara" {
|
||||||
|
t.Fatalf("input/voice 映射错误: %v", out)
|
||||||
|
}
|
||||||
|
if _, ok := out["text"]; ok {
|
||||||
|
t.Fatal("text 字段应被移除")
|
||||||
|
}
|
||||||
|
if _, ok := out["voice_id"]; ok {
|
||||||
|
t.Fatal("voice_id 字段应被移除")
|
||||||
|
}
|
||||||
|
of, _ := out["output_format"].(map[string]any)
|
||||||
|
if of == nil || of["codec"] != "mp3" {
|
||||||
|
t.Fatalf("output_format 应原样保留: %v", out["output_format"])
|
||||||
|
}
|
||||||
|
if out["language"] != "zh" || out["speed"] == nil {
|
||||||
|
t.Fatalf("language/speed 应保留: %v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestModerationInputs 断言 input 的 string / []string 解析与边界校验。
|
||||||
|
func TestModerationInputs(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
wantN int
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"单字符串", `"hello"`, 1, false},
|
||||||
|
{"数组", `["a","b"]`, 2, false},
|
||||||
|
{"空字符串拒绝", `""`, 0, true},
|
||||||
|
{"空数组拒绝", `[]`, 0, true},
|
||||||
|
{"含空条目拒绝", `["a",""]`, 0, true},
|
||||||
|
{"超上限拒绝", `["1","2","3","4","5","6","7","8","9"]`, 0, true},
|
||||||
|
{"非法类型拒绝", `{"x":1}`, 0, true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ModerationInputs(json.RawMessage(tt.raw))
|
||||||
|
if (err != nil) != tt.wantErr || len(got) != tt.wantN {
|
||||||
|
t.Fatalf("got %v (err=%v), want n=%d wantErr=%v", got, err, tt.wantN, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestModerationResultMapping 断言 guardrails 结果到 OpenAI 外壳的映射与 flagged 判定。
|
||||||
|
func TestModerationResultMapping(t *testing.T) {
|
||||||
|
one := 1.0
|
||||||
|
zero := 0.0
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
outcome oci.GuardrailsOutcome
|
||||||
|
wantFlagged bool
|
||||||
|
wantPii int
|
||||||
|
}{
|
||||||
|
{"内容审核命中", oci.GuardrailsOutcome{Categories: []oci.GuardrailCategory{{Name: "OVERALL", Score: 1}}}, true, 0},
|
||||||
|
{"提示注入命中", oci.GuardrailsOutcome{PromptInjectionScore: &one}, true, 0},
|
||||||
|
{"仅 PII 不 flag", oci.GuardrailsOutcome{Categories: []oci.GuardrailCategory{{Name: "OVERALL", Score: 0}},
|
||||||
|
PromptInjectionScore: &zero, Pii: []oci.GuardrailPiiHit{{Text: "Jane", Label: "PERSON", Score: 0.99}}}, false, 1},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
res := moderationResult(&tt.outcome)
|
||||||
|
if res.Flagged != tt.wantFlagged || len(res.Pii) != tt.wantPii {
|
||||||
|
t.Fatalf("res = %+v, want flagged=%v pii=%d", res, tt.wantFlagged, tt.wantPii)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAiRerank 断言重排编排:能力路由、top_n 透传、return_documents 回填与越界防御。
|
||||||
|
func TestAiRerank(t *testing.T) {
|
||||||
|
client := &gatewayStubClient{
|
||||||
|
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||||
|
rerankRanks: []oci.RerankRank{{Index: 1, Score: 0.9}, {Index: 0, Score: 0.4}, {Index: 9, Score: 0.1}},
|
||||||
|
}
|
||||||
|
gw, svc := newTestGateway(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
ch := seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
|
||||||
|
gw.db.Create(&model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..rr", Name: "cohere.rerank-v4.0-fast",
|
||||||
|
Vendor: "cohere", Capability: "RERANK", SyncedAt: time.Now()})
|
||||||
|
ctx := context.Background()
|
||||||
|
yes := true
|
||||||
|
req := aiwire.RerankRequest{Model: "cohere.rerank-v4.0-fast", Query: "q", Documents: []string{"d0", "d1"}, ReturnDocuments: &yes}
|
||||||
|
resp, _, err := gw.Rerank(ctx, req, "")
|
||||||
|
if err != nil || len(resp.Results) != 2 {
|
||||||
|
t.Fatalf("Rerank = %+v, %v(越界 index 应被丢弃)", resp, err)
|
||||||
|
}
|
||||||
|
if resp.Results[0].Index != 1 || resp.Results[0].Document == nil || resp.Results[0].Document.Text != "d1" {
|
||||||
|
t.Fatalf("results[0] = %+v", resp.Results[0])
|
||||||
|
}
|
||||||
|
// 对话模型名打 rerank:能力不匹配 → 未知模型
|
||||||
|
if _, _, err := gw.Rerank(ctx, aiwire.RerankRequest{Model: "meta.llama-3.3-70b-instruct", Query: "q", Documents: []string{"d"}}, ""); !errors.Is(err, ErrAiUnknownModel) {
|
||||||
|
t.Errorf("chat 模型走 rerank err = %v, want ErrAiUnknownModel", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAiSpeech 断言 TTS 编排走 TTS 能力路由并透传音频与 Content-Type。
|
||||||
|
func TestAiSpeech(t *testing.T) {
|
||||||
|
client := &gatewayStubClient{
|
||||||
|
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||||
|
speechAudio: []byte{0xFF, 0xF3}, speechCT: "audio/mpeg",
|
||||||
|
}
|
||||||
|
gw, svc := newTestGateway(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
ch := seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
|
||||||
|
gw.db.Create(&model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..tts", Name: "xai.grok-tts",
|
||||||
|
Vendor: "xai", Capability: "TTS", SyncedAt: time.Now()})
|
||||||
|
audio, ct, _, err := gw.Speech(context.Background(), "xai.grok-tts", []byte(`{"model":"xai.grok-tts","input":"你好","language":"auto"}`), "")
|
||||||
|
if err != nil || ct != "audio/mpeg" || len(audio) != 2 {
|
||||||
|
t.Fatalf("Speech = %d bytes, ct=%s, %v", len(audio), ct, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAiModerations 断言审核编排:无模型维度按分组选渠道,多条输入逐条聚合。
|
||||||
|
func TestAiModerations(t *testing.T) {
|
||||||
|
one := 1.0
|
||||||
|
client := &gatewayStubClient{
|
||||||
|
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||||
|
guardOutcome: &oci.GuardrailsOutcome{Categories: []oci.GuardrailCategory{{Name: "OVERALL", Score: 1}}, PromptInjectionScore: &one},
|
||||||
|
}
|
||||||
|
gw, svc := newTestGateway(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
|
||||||
|
resp, meta, err := gw.Moderations(context.Background(), "modr_test", []string{"a", "b"}, "")
|
||||||
|
if err != nil || len(resp.Results) != 2 || !resp.Results[0].Flagged || resp.ID != "modr_test" {
|
||||||
|
t.Fatalf("Moderations = %+v, meta=%+v, %v", resp, meta, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Model, "guardrails") {
|
||||||
|
t.Errorf("model = %s", resp.Model)
|
||||||
|
}
|
||||||
|
// 分组不匹配 → 无可用渠道
|
||||||
|
if _, _, err := gw.Moderations(context.Background(), "modr_x", []string{"a"}, "ghost-group"); !errors.Is(err, ErrAiNoChannel) {
|
||||||
|
t.Errorf("ghost 分组 err = %v, want ErrAiNoChannel", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,26 @@ type gatewayStubClient struct {
|
|||||||
passErrs []error
|
passErrs []error
|
||||||
passCalls int
|
passCalls int
|
||||||
passRegions []string
|
passRegions []string
|
||||||
|
|
||||||
|
speechAudio []byte
|
||||||
|
speechCT string
|
||||||
|
speechErr error
|
||||||
|
rerankRanks []oci.RerankRank
|
||||||
|
rerankErr error
|
||||||
|
guardOutcome *oci.GuardrailsOutcome
|
||||||
|
guardErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *gatewayStubClient) GenAiCompatSpeech(ctx context.Context, cred oci.Credentials, region string, body []byte) ([]byte, string, error) {
|
||||||
|
return f.speechAudio, f.speechCT, f.speechErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *gatewayStubClient) GenAiRerank(ctx context.Context, cred oci.Credentials, region, modelOcid, query string, documents []string, topN *int) ([]oci.RerankRank, error) {
|
||||||
|
return f.rerankRanks, f.rerankErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *gatewayStubClient) GenAiApplyGuardrails(ctx context.Context, cred oci.Credentials, region, text string) (*oci.GuardrailsOutcome, error) {
|
||||||
|
return f.guardOutcome, f.guardErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *gatewayStubClient) GenAiCompatResponses(ctx context.Context, cred oci.Credentials, region string, body []byte) ([]byte, error) {
|
func (f *gatewayStubClient) GenAiCompatResponses(ctx context.Context, cred oci.Credentials, region string, body []byte) ([]byte, error) {
|
||||||
@@ -116,7 +136,7 @@ func (f *gatewayStubClient) GenAiProbeChat(ctx context.Context, cred oci.Credent
|
|||||||
func newTestGateway(t *testing.T, client oci.Client) (*AiGatewayService, *OciConfigService) {
|
func newTestGateway(t *testing.T, client oci.Client) (*AiGatewayService, *OciConfigService) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
svc := newTestService(t, client)
|
svc := newTestService(t, client)
|
||||||
if err := svc.db.AutoMigrate(&model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiModelBlacklist{}, &model.AiCallLog{}); err != nil {
|
if err := svc.db.AutoMigrate(&model.Setting{}, &model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiModelBlacklist{}, &model.AiCallLog{}); err != nil {
|
||||||
t.Fatalf("auto migrate ai tables: %v", err)
|
t.Fatalf("auto migrate ai tables: %v", err)
|
||||||
}
|
}
|
||||||
return NewAiGatewayService(svc.db, svc, client), svc
|
return NewAiGatewayService(svc.db, svc, client), svc
|
||||||
@@ -208,6 +228,46 @@ func seedChannel(t *testing.T, gw *AiGatewayService, cfgID uint, region string,
|
|||||||
return ch
|
return ch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFilterDeprecatedModels 断言「过滤弃用模型」开关对列表 / 路由的过滤与持久化。
|
||||||
|
func TestFilterDeprecatedModels(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()
|
||||||
|
|
||||||
|
list, _ := gw.GatewayModels(ctx, "")
|
||||||
|
if len(list.Data) != 2 {
|
||||||
|
t.Fatalf("开关关:应含弃用模型,got %d", len(list.Data))
|
||||||
|
}
|
||||||
|
if err := gw.SetFilterDeprecated(ctx, true); err != nil {
|
||||||
|
t.Fatalf("SetFilterDeprecated: %v", err)
|
||||||
|
}
|
||||||
|
list, _ = gw.GatewayModels(ctx, "")
|
||||||
|
if len(list.Data) != 1 || list.Data[0].ID != "meta.llama-3.3-70b-instruct" {
|
||||||
|
t.Fatalf("开关开:弃用模型应被过滤,got %+v", list.Data)
|
||||||
|
}
|
||||||
|
if _, err := gw.pick(ctx, "meta.llama-old", "", "CHAT", map[uint]bool{}); !errors.Is(err, ErrAiUnknownModel) {
|
||||||
|
t.Errorf("开关开:弃用模型路由应不可达,err = %v", err)
|
||||||
|
}
|
||||||
|
// 持久化:重建 service 后开关仍生效
|
||||||
|
gw2 := NewAiGatewayService(gw.db, svc, &gatewayStubClient{})
|
||||||
|
if !gw2.FilterDeprecated() {
|
||||||
|
t.Error("重建后开关状态应保持开启")
|
||||||
|
}
|
||||||
|
if err := gw.SetFilterDeprecated(ctx, false); err != nil {
|
||||||
|
t.Fatalf("关闭开关: %v", err)
|
||||||
|
}
|
||||||
|
if list, _ = gw.GatewayModels(ctx, ""); len(list.Data) != 2 {
|
||||||
|
t.Errorf("开关关:弃用模型应恢复,got %d", len(list.Data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPickPriorityAndBreaker(t *testing.T) {
|
func TestPickPriorityAndBreaker(t *testing.T) {
|
||||||
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
||||||
cfg := importAliveConfig(t, svc)
|
cfg := importAliveConfig(t, svc)
|
||||||
|
|||||||
@@ -22,34 +22,21 @@ func respRejectStateful(req aiwire.RespRequest) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RespServerTools 报告工具列表是否含 xAI 服务端工具(web_search / x_search)。
|
|
||||||
func RespServerTools(tools []aiwire.RespTool) bool {
|
|
||||||
for _, t := range tools {
|
|
||||||
if t.Type == "web_search" || t.Type == "x_search" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// RespPassthroughValidate 校验直通请求:模型必填,有状态特性不支持,工具类型
|
// RespPassthroughValidate 校验直通请求:模型必填,有状态特性不支持,工具类型
|
||||||
// 只放行 function 与已实测的 web_search / x_search;流式仅在含服务端工具时拒绝
|
// 只放行 function 与 Oracle 文档化的服务端工具(web_search / x_search /
|
||||||
// (工具流式事件形态未实测,不放开)。
|
// code_interpreter / mcp)。
|
||||||
func RespPassthroughValidate(req aiwire.RespRequest) error {
|
func RespPassthroughValidate(req aiwire.RespRequest) error {
|
||||||
if strings.TrimSpace(req.Model) == "" {
|
if strings.TrimSpace(req.Model) == "" {
|
||||||
return fmt.Errorf("model 不能为空")
|
return fmt.Errorf("model 不能为空")
|
||||||
}
|
}
|
||||||
if req.Stream && RespServerTools(req.Tools) {
|
|
||||||
return fmt.Errorf("服务端工具暂不支持流式:请去掉 stream 或改用 function 工具")
|
|
||||||
}
|
|
||||||
if err := respRejectStateful(req); err != nil {
|
if err := respRejectStateful(req); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, t := range req.Tools {
|
for _, t := range req.Tools {
|
||||||
switch t.Type {
|
switch t.Type {
|
||||||
case "function", "web_search", "x_search":
|
case "function", "web_search", "x_search", "code_interpreter", "mcp":
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("不支持的工具类型 %q:服务端工具仅支持 web_search / x_search", t.Type)
|
return fmt.Errorf("不支持的工具类型 %q:服务端工具仅支持 web_search / x_search / code_interpreter / mcp", t.Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -104,3 +91,25 @@ func RespStreamCompletedUsage(data []byte) *aiwire.Usage {
|
|||||||
}
|
}
|
||||||
return RespPassthroughUsage(ev.Response)
|
return RespPassthroughUsage(ev.Response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RespStreamErrorMsg 从一行 SSE data JSON 中提取 error / response.failed 事件的
|
||||||
|
// 错误消息;非错误事件返回空串。流式直通据此把上游错误写入调用日志。
|
||||||
|
func RespStreamErrorMsg(data []byte) string {
|
||||||
|
var ev respStreamEvent
|
||||||
|
if json.Unmarshal(data, &ev) != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch ev.Type {
|
||||||
|
case "error":
|
||||||
|
if ev.Message != "" {
|
||||||
|
return ev.Message
|
||||||
|
}
|
||||||
|
return "上游返回错误事件 error"
|
||||||
|
case "response.failed":
|
||||||
|
if m := respErrorMsg(ev.Response); m != "" {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return "上游返回错误事件 response.failed"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,27 +24,6 @@ func deref(p *int) int {
|
|||||||
return *p
|
return *p
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRespServerTools(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
tools []aiwire.RespTool
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"web_search", []aiwire.RespTool{{Type: "web_search"}}, true},
|
|
||||||
{"x_search混用", []aiwire.RespTool{{Type: "function", Name: "f"}, {Type: "x_search"}}, true},
|
|
||||||
{"仅function", []aiwire.RespTool{{Type: "function", Name: "f"}}, false},
|
|
||||||
{"空", nil, false},
|
|
||||||
{"其他类型", []aiwire.RespTool{{Type: "code_interpreter"}}, false},
|
|
||||||
}
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
if got := RespServerTools(test.tools); got != test.want {
|
|
||||||
t.Fatalf("got %v, want %v", got, test.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRespPassthroughValidate(t *testing.T) {
|
func TestRespPassthroughValidate(t *testing.T) {
|
||||||
prev := "resp_1"
|
prev := "resp_1"
|
||||||
bg := true
|
bg := true
|
||||||
@@ -56,12 +35,14 @@ func TestRespPassthroughValidate(t *testing.T) {
|
|||||||
{"合法", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "web_search"}}}, false},
|
{"合法", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "web_search"}}}, false},
|
||||||
{"混用function", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "web_search"}, {Type: "function", Name: "f"}}}, false},
|
{"混用function", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "web_search"}, {Type: "function", Name: "f"}}}, false},
|
||||||
{"缺model", aiwire.RespRequest{Tools: []aiwire.RespTool{{Type: "web_search"}}}, true},
|
{"缺model", aiwire.RespRequest{Tools: []aiwire.RespTool{{Type: "web_search"}}}, true},
|
||||||
{"工具加流式拒绝", aiwire.RespRequest{Model: "m", Stream: true, Tools: []aiwire.RespTool{{Type: "web_search"}}}, true},
|
{"服务端工具流式放行", aiwire.RespRequest{Model: "m", Stream: true, Tools: []aiwire.RespTool{{Type: "web_search"}}}, false},
|
||||||
{"无工具流式放行", aiwire.RespRequest{Model: "m", Stream: true}, false},
|
{"无工具流式放行", aiwire.RespRequest{Model: "m", Stream: true}, false},
|
||||||
{"function工具流式放行", aiwire.RespRequest{Model: "m", Stream: true, Tools: []aiwire.RespTool{{Type: "function", Name: "f"}}}, false},
|
{"function工具流式放行", aiwire.RespRequest{Model: "m", Stream: true, Tools: []aiwire.RespTool{{Type: "function", Name: "f"}}}, false},
|
||||||
|
{"code_interpreter放行", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "code_interpreter"}}}, false},
|
||||||
|
{"mcp流式放行", aiwire.RespRequest{Model: "m", Stream: true, Tools: []aiwire.RespTool{{Type: "mcp"}}}, false},
|
||||||
{"有状态拒绝", aiwire.RespRequest{Model: "m", PreviousResponseID: prev}, true},
|
{"有状态拒绝", aiwire.RespRequest{Model: "m", PreviousResponseID: prev}, true},
|
||||||
{"background拒绝", aiwire.RespRequest{Model: "m", Background: &bg}, true},
|
{"background拒绝", aiwire.RespRequest{Model: "m", Background: &bg}, true},
|
||||||
{"未知工具拒绝", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "web_search"}, {Type: "mcp"}}}, true},
|
{"未知工具拒绝", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "web_search"}, {Type: "file_search"}}}, true},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ type AnthEvent struct {
|
|||||||
|
|
||||||
// AnthRespBridge 把直通 SSE 事件流桥接为 Anthropic 事件序列:
|
// AnthRespBridge 把直通 SSE 事件流桥接为 Anthropic 事件序列:
|
||||||
// message_start → content_block_start/delta/stop(text 与 tool_use 分块)→ message_delta → message_stop。
|
// message_start → content_block_start/delta/stop(text 与 tool_use 分块)→ message_delta → message_stop。
|
||||||
// reasoning 系列事件丢弃。
|
// reasoning 系列事件丢弃;上游 error / response.failed 转 Anthropic error 事件透传。
|
||||||
type AnthRespBridge struct {
|
type AnthRespBridge struct {
|
||||||
id, model string
|
id, model string
|
||||||
started bool
|
started bool
|
||||||
@@ -267,6 +267,11 @@ type AnthRespBridge struct {
|
|||||||
blockIndex int
|
blockIndex int
|
||||||
stopReason string
|
stopReason string
|
||||||
usage aiwire.AnthUsage
|
usage aiwire.AnthUsage
|
||||||
|
// sawTerminal 标记收到过终态事件(completed/incomplete/failed);
|
||||||
|
// 上游流提前 EOF 时据此发 error 事件而非伪装正常结束
|
||||||
|
sawTerminal bool
|
||||||
|
// errMsg 记录上游错误消息,非空即本流已失败(供调用日志)
|
||||||
|
errMsg string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAnthRespBridge 构造桥;id 为响应消息 ID。
|
// NewAnthRespBridge 构造桥;id 为响应消息 ID。
|
||||||
@@ -278,6 +283,7 @@ func NewAnthRespBridge(id, model string) *AnthRespBridge {
|
|||||||
type respStreamEvent struct {
|
type respStreamEvent struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Delta string `json:"delta"`
|
Delta string `json:"delta"`
|
||||||
|
Message string `json:"message"`
|
||||||
Item *respOutputItem `json:"item"`
|
Item *respOutputItem `json:"item"`
|
||||||
Response *respPayload `json:"response"`
|
Response *respPayload `json:"response"`
|
||||||
}
|
}
|
||||||
@@ -288,27 +294,130 @@ func (st *AnthRespBridge) Feed(data []byte) []AnthEvent {
|
|||||||
if json.Unmarshal(data, &ev) != nil {
|
if json.Unmarshal(data, &ev) != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var events []AnthEvent
|
if ev.Type == "error" || ev.Type == "response.failed" {
|
||||||
if !st.started {
|
return st.failWith(ev)
|
||||||
st.started = true
|
|
||||||
events = append(events, st.startEvent())
|
|
||||||
}
|
}
|
||||||
|
// message_start 延迟到首个可见输出事件:created / reasoning 阶段不向客户端
|
||||||
|
// 写任何字节,上游此段断流时 handler 才有降级非流式重做的无感窗口
|
||||||
|
var events []AnthEvent
|
||||||
switch ev.Type {
|
switch ev.Type {
|
||||||
case "response.output_item.added":
|
case "response.output_item.added":
|
||||||
if ev.Item != nil && ev.Item.Type == "function_call" {
|
if ev.Item != nil && ev.Item.Type == "function_call" {
|
||||||
|
events = append(events, st.ensureStarted()...)
|
||||||
events = append(events, st.openBlock(true, ev.Item.CallID, ev.Item.Name)...)
|
events = append(events, st.openBlock(true, ev.Item.CallID, ev.Item.Name)...)
|
||||||
st.stopReason = "tool_use"
|
st.stopReason = "tool_use"
|
||||||
}
|
}
|
||||||
case "response.output_text.delta":
|
case "response.output_text.delta":
|
||||||
|
events = append(events, st.ensureStarted()...)
|
||||||
events = append(events, st.textDelta(ev.Delta)...)
|
events = append(events, st.textDelta(ev.Delta)...)
|
||||||
case "response.function_call_arguments.delta":
|
case "response.function_call_arguments.delta":
|
||||||
events = append(events, st.argsDelta(ev.Delta)...)
|
events = append(events, st.argsDelta(ev.Delta)...)
|
||||||
case "response.completed", "response.incomplete", "response.failed":
|
case "response.completed", "response.incomplete":
|
||||||
|
st.sawTerminal = true
|
||||||
st.finishFrom(ev.Response)
|
st.finishFrom(ev.Response)
|
||||||
}
|
}
|
||||||
return events
|
return events
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureStarted 在首个可见输出事件前补发 message_start(仅一次)。
|
||||||
|
func (st *AnthRespBridge) ensureStarted() []AnthEvent {
|
||||||
|
if st.started {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
st.started = true
|
||||||
|
return []AnthEvent{st.startEvent()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// failWith 记录上游错误并产出 Anthropic error 事件(每流至多一次)。
|
||||||
|
func (st *AnthRespBridge) failWith(ev respStreamEvent) []AnthEvent {
|
||||||
|
if st.errMsg != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
st.sawTerminal = true
|
||||||
|
msg := ev.Message
|
||||||
|
if ev.Type == "response.failed" {
|
||||||
|
st.finishFrom(ev.Response)
|
||||||
|
if m := respErrorMsg(ev.Response); m != "" {
|
||||||
|
msg = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
msg = "上游返回错误事件 " + ev.Type
|
||||||
|
}
|
||||||
|
st.errMsg = msg
|
||||||
|
return []AnthEvent{st.errorEvent()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// respErrorMsg 提取 response.failed 载荷中的错误消息。
|
||||||
|
func respErrorMsg(resp *respPayload) string {
|
||||||
|
if resp == nil || resp.Error == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return (*resp.Error)["message"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorEvent 按 Anthropic 流式协议产出 error 事件。
|
||||||
|
func (st *AnthRespBridge) errorEvent() AnthEvent {
|
||||||
|
return AnthEvent{Event: "error", Data: map[string]any{
|
||||||
|
"type": "error",
|
||||||
|
"error": map[string]string{"type": "api_error", "message": st.errMsg},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Err 返回上游错误消息;空串表示流正常(供调用日志)。
|
||||||
|
func (st *AnthRespBridge) Err() string { return st.errMsg }
|
||||||
|
|
||||||
|
// SawTerminal 报告是否收到过终态事件;false 即上游流提前终止。
|
||||||
|
func (st *AnthRespBridge) SawTerminal() bool { return st.sawTerminal }
|
||||||
|
|
||||||
|
// AnthMessageEvents 把完整 Messages 响应展开为标准事件序列,
|
||||||
|
// 供流式上游断流后的非流式降级结果推送(客户端协议不变)。
|
||||||
|
func AnthMessageEvents(m *aiwire.MessagesResponse) []AnthEvent {
|
||||||
|
events := []AnthEvent{{Event: "message_start", Data: map[string]any{
|
||||||
|
"type": "message_start",
|
||||||
|
"message": map[string]any{
|
||||||
|
"id": m.ID, "type": "message", "role": m.Role, "model": m.Model,
|
||||||
|
"content": []any{}, "stop_reason": nil,
|
||||||
|
"usage": map[string]int{"input_tokens": 0, "output_tokens": 0},
|
||||||
|
},
|
||||||
|
}}}
|
||||||
|
for i, b := range m.Content {
|
||||||
|
events = append(events, anthBlockEvents(i, b)...)
|
||||||
|
}
|
||||||
|
usage := map[string]int{"input_tokens": m.Usage.InputTokens, "output_tokens": m.Usage.OutputTokens}
|
||||||
|
if m.Usage.CacheReadInputTokens > 0 {
|
||||||
|
usage["cache_read_input_tokens"] = m.Usage.CacheReadInputTokens
|
||||||
|
}
|
||||||
|
events = append(events,
|
||||||
|
AnthEvent{Event: "message_delta", Data: map[string]any{
|
||||||
|
"type": "message_delta",
|
||||||
|
"delta": map[string]any{"stop_reason": m.StopReason, "stop_sequence": nil},
|
||||||
|
"usage": usage,
|
||||||
|
}},
|
||||||
|
AnthEvent{Event: "message_stop", Data: map[string]any{"type": "message_stop"}})
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
// anthBlockEvents 把一个内容块展开为 start / delta / stop 三事件。
|
||||||
|
func anthBlockEvents(index int, b aiwire.AnthBlock) []AnthEvent {
|
||||||
|
var start, delta map[string]any
|
||||||
|
if b.Type == "tool_use" {
|
||||||
|
start = map[string]any{"type": "tool_use", "id": b.ID, "name": b.Name, "input": map[string]any{}}
|
||||||
|
delta = map[string]any{"type": "input_json_delta", "partial_json": string(b.Input)}
|
||||||
|
} else {
|
||||||
|
start = map[string]any{"type": "text", "text": ""}
|
||||||
|
delta = map[string]any{"type": "text_delta", "text": b.Text}
|
||||||
|
}
|
||||||
|
return []AnthEvent{
|
||||||
|
{Event: "content_block_start", Data: map[string]any{
|
||||||
|
"type": "content_block_start", "index": index, "content_block": start}},
|
||||||
|
{Event: "content_block_delta", Data: map[string]any{
|
||||||
|
"type": "content_block_delta", "index": index, "delta": delta}},
|
||||||
|
{Event: "content_block_stop", Data: map[string]any{
|
||||||
|
"type": "content_block_stop", "index": index}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (st *AnthRespBridge) textDelta(delta string) []AnthEvent {
|
func (st *AnthRespBridge) textDelta(delta string) []AnthEvent {
|
||||||
var events []AnthEvent
|
var events []AnthEvent
|
||||||
if !st.blockOpen || st.blockIsTool {
|
if !st.blockOpen || st.blockIsTool {
|
||||||
@@ -378,7 +487,16 @@ func (st *AnthRespBridge) closeBlockEvent() AnthEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Finish 在上游流结束后收尾:关块 → message_delta(stop_reason+usage)→ message_stop。
|
// Finish 在上游流结束后收尾:关块 → message_delta(stop_reason+usage)→ message_stop。
|
||||||
|
// 已发过 error 事件的流不再补终态;未见终态事件即 EOF 视为上游提前终止,
|
||||||
|
// 发 error 事件而非伪装正常结束(否则客户端拿到"成功的空消息")。
|
||||||
func (st *AnthRespBridge) Finish() []AnthEvent {
|
func (st *AnthRespBridge) Finish() []AnthEvent {
|
||||||
|
if st.errMsg != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !st.sawTerminal {
|
||||||
|
st.errMsg = "上游流提前终止,未返回终态事件"
|
||||||
|
return []AnthEvent{st.errorEvent()}
|
||||||
|
}
|
||||||
var events []AnthEvent
|
var events []AnthEvent
|
||||||
if !st.started {
|
if !st.started {
|
||||||
st.started = true
|
st.started = true
|
||||||
|
|||||||
@@ -164,10 +164,105 @@ func TestAnthRespBridge(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAnthRespBridgeEmpty 断言空流也产出完整事件骨架。
|
// TestAnthRespBridgeFailure 断言异常流的错误透传:上游 error / response.failed
|
||||||
func TestAnthRespBridgeEmpty(t *testing.T) {
|
// 事件转 Anthropic error 事件,提前 EOF(未见终态)不再伪装正常结束。
|
||||||
|
func TestAnthRespBridgeFailure(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
lines []string
|
||||||
|
wantKinds string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "上游 error 事件透传",
|
||||||
|
lines: []string{`{"type":"error","message":"model overloaded"}`},
|
||||||
|
wantKinds: "error",
|
||||||
|
wantErr: "model overloaded",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "response.failed 提取错误消息",
|
||||||
|
lines: []string{
|
||||||
|
`{"type":"response.output_text.delta","delta":"你"}`,
|
||||||
|
`{"type":"response.failed","response":{"status":"failed","error":{"message":"content filtered"}}}`,
|
||||||
|
},
|
||||||
|
wantKinds: "message_start,content_block_start,content_block_delta,error",
|
||||||
|
wantErr: "content filtered",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "空流提前终止",
|
||||||
|
lines: nil,
|
||||||
|
wantKinds: "error",
|
||||||
|
wantErr: "上游流提前终止,未返回终态事件",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "输出中途 EOF 无终态",
|
||||||
|
lines: []string{
|
||||||
|
`{"type":"response.output_text.delta","delta":"你"}`,
|
||||||
|
},
|
||||||
|
wantKinds: "message_start,content_block_start,content_block_delta,error",
|
||||||
|
wantErr: "上游流提前终止,未返回终态事件",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
st := NewAnthRespBridge("msg_1", "m1")
|
st := NewAnthRespBridge("msg_1", "m1")
|
||||||
if got := bridgeEventTypes(st.Finish()); got != "message_start,message_delta,message_stop" {
|
var events []AnthEvent
|
||||||
t.Errorf("空流事件序列 = %s", got)
|
for _, l := range tc.lines {
|
||||||
|
events = append(events, st.Feed([]byte(l))...)
|
||||||
|
}
|
||||||
|
events = append(events, st.Finish()...)
|
||||||
|
if got := bridgeEventTypes(events); got != tc.wantKinds {
|
||||||
|
t.Fatalf("事件序列 = %s, want %s", got, tc.wantKinds)
|
||||||
|
}
|
||||||
|
if st.Err() != tc.wantErr {
|
||||||
|
t.Fatalf("Err() = %q, want %q", st.Err(), tc.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRespStreamErrorMsg 断言直通流错误事件消息提取。
|
||||||
|
func TestRespStreamErrorMsg(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
data string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "error 事件", data: `{"type":"error","message":"boom"}`, want: "boom"},
|
||||||
|
{name: "error 无消息用占位", data: `{"type":"error"}`, want: "上游返回错误事件 error"},
|
||||||
|
{name: "failed 事件", data: `{"type":"response.failed","response":{"error":{"message":"bad"}}}`, want: "bad"},
|
||||||
|
{name: "正常事件返回空", data: `{"type":"response.completed","response":{}}`, want: ""},
|
||||||
|
{name: "非 JSON 返回空", data: `<html>`, want: ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := RespStreamErrorMsg([]byte(tc.data)); got != tc.want {
|
||||||
|
t.Fatalf("RespStreamErrorMsg = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnthMessageEvents 断言非流式降级结果展开的事件序列与 usage。
|
||||||
|
func TestAnthMessageEvents(t *testing.T) {
|
||||||
|
msg := &aiwire.MessagesResponse{
|
||||||
|
ID: "msg_1", Type: "message", Role: "assistant", Model: "m1",
|
||||||
|
Content: []aiwire.AnthBlock{
|
||||||
|
{Type: "text", Text: "好"},
|
||||||
|
{Type: "tool_use", ID: "c1", Name: "f", Input: json.RawMessage(`{"a":1}`)},
|
||||||
|
},
|
||||||
|
StopReason: "tool_use",
|
||||||
|
Usage: aiwire.AnthUsage{InputTokens: 9, OutputTokens: 3, CacheReadInputTokens: 5},
|
||||||
|
}
|
||||||
|
events := AnthMessageEvents(msg)
|
||||||
|
want := "message_start,content_block_start,content_block_delta,content_block_stop," +
|
||||||
|
"content_block_start,content_block_delta,content_block_stop,message_delta,message_stop"
|
||||||
|
if got := bridgeEventTypes(events); got != want {
|
||||||
|
t.Fatalf("事件序列:\n got %s\nwant %s", got, want)
|
||||||
|
}
|
||||||
|
delta := events[len(events)-2].Data.(map[string]any)
|
||||||
|
usage := delta["usage"].(map[string]int)
|
||||||
|
if usage["input_tokens"] != 9 || usage["output_tokens"] != 3 || usage["cache_read_input_tokens"] != 5 {
|
||||||
|
t.Fatalf("usage = %+v", usage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -358,3 +358,32 @@ func (b *ChatRespBridge) Finish() []aiwire.ChatChunk {
|
|||||||
|
|
||||||
// Usage 返回聚合到的用量(供调用日志),上游未报告时为 nil。
|
// Usage 返回聚合到的用量(供调用日志),上游未报告时为 nil。
|
||||||
func (b *ChatRespBridge) Usage() *aiwire.Usage { return b.usage }
|
func (b *ChatRespBridge) Usage() *aiwire.Usage { return b.usage }
|
||||||
|
|
||||||
|
// ChatResponseChunks 把完整 Chat 响应展开为 chunk 序列(内容与工具调用 →
|
||||||
|
// 终块 → 可选 usage 块),供流式上游断流后的非流式降级结果推送。
|
||||||
|
func ChatResponseChunks(resp *aiwire.ChatResponse, includeUsage bool) []aiwire.ChatChunk {
|
||||||
|
if len(resp.Choices) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
choice := resp.Choices[0]
|
||||||
|
mk := func(delta aiwire.Delta, finish *string) aiwire.ChatChunk {
|
||||||
|
return aiwire.ChatChunk{ID: resp.ID, Object: "chat.completion.chunk", Created: resp.Created,
|
||||||
|
Model: resp.Model, Choices: []aiwire.ChunkChoice{{Index: 0, Delta: delta, FinishReason: finish}}}
|
||||||
|
}
|
||||||
|
delta := aiwire.Delta{Role: "assistant", Content: choice.Message.Content.Text}
|
||||||
|
for i, tc := range choice.Message.ToolCalls {
|
||||||
|
delta.ToolCalls = append(delta.ToolCalls, aiwire.ToolCallDelta{Index: i, ID: tc.ID,
|
||||||
|
Type: "function", Function: aiwire.FunctionCallDelta{Name: tc.Function.Name, Arguments: tc.Function.Arguments}})
|
||||||
|
}
|
||||||
|
finish := choice.FinishReason
|
||||||
|
chunks := []aiwire.ChatChunk{mk(delta, nil), mk(aiwire.Delta{}, &finish)}
|
||||||
|
if includeUsage {
|
||||||
|
usage := resp.Usage
|
||||||
|
if usage == nil {
|
||||||
|
usage = &aiwire.Usage{}
|
||||||
|
}
|
||||||
|
chunks = append(chunks, aiwire.ChatChunk{ID: resp.ID, Object: "chat.completion.chunk",
|
||||||
|
Created: resp.Created, Model: resp.Model, Choices: []aiwire.ChunkChoice{}, Usage: usage})
|
||||||
|
}
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ type externalIdentity struct {
|
|||||||
|
|
||||||
// HandleCallback 完成授权码回调:换取身份后,bind 模式写绑定、login 模式签发 JWT;
|
// HandleCallback 完成授权码回调:换取身份后,bind 模式写绑定、login 模式签发 JWT;
|
||||||
// token 仅 login 模式非空;mode 尽力返回(state 无效时为空),供 api 决定错误回跳页面。
|
// token 仅 login 模式非空;mode 尽力返回(state 无效时为空),供 api 决定错误回跳页面。
|
||||||
func (o *OAuthService) HandleCallback(ctx context.Context, provider, state, code string) (token, display, mode string, err error) {
|
func (o *OAuthService) HandleCallback(ctx context.Context, provider, state, code string) (token, username, mode string, err error) {
|
||||||
p, err := o.takeState(provider, state)
|
p, err := o.takeState(provider, state)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", "", err
|
return "", "", "", err
|
||||||
@@ -198,14 +198,14 @@ func (o *OAuthService) HandleCallback(ctx context.Context, provider, state, code
|
|||||||
}
|
}
|
||||||
if p.mode == "bind" {
|
if p.mode == "bind" {
|
||||||
if err := o.bind(ctx, p.username, provider, ident); err != nil {
|
if err := o.bind(ctx, p.username, provider, ident); err != nil {
|
||||||
return "", ident.Display, p.mode, err
|
return "", p.username, p.mode, err
|
||||||
}
|
}
|
||||||
// 绑定属敏感变更:版本递增使旧令牌失效,同时为操作者签新令牌随回跳带回
|
// 绑定属敏感变更:版本递增使旧令牌失效,同时为操作者签新令牌随回跳带回
|
||||||
token, _, err := o.auth.RevokeSessions(ctx, p.username)
|
token, _, err := o.auth.RevokeSessions(ctx, p.username)
|
||||||
return token, ident.Display, p.mode, err
|
return token, p.username, p.mode, err
|
||||||
}
|
}
|
||||||
token, display, err = o.loginByIdentity(ctx, provider, ident)
|
token, username, err = o.loginByIdentity(ctx, provider, ident)
|
||||||
return token, display, p.mode, err
|
return token, username, p.mode, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchIdentity 用授权码向 provider 换取稳定 subject 与展示名。
|
// fetchIdentity 用授权码向 provider 换取稳定 subject 与展示名。
|
||||||
@@ -311,7 +311,7 @@ func (o *OAuthService) loginByIdentity(ctx context.Context, provider string, ide
|
|||||||
return "", "", fmt.Errorf("find bound user: %w", err)
|
return "", "", fmt.Errorf("find bound user: %w", err)
|
||||||
}
|
}
|
||||||
token, _, err := o.auth.signToken(user.Username, user.TokenVersion)
|
token, _, err := o.auth.signToken(user.Username, user.TokenVersion)
|
||||||
return token, ident.Display, err
|
return token, user.Username, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Identities 列出账号已绑定的外部身份。
|
// Identities 列出账号已绑定的外部身份。
|
||||||
|
|||||||
@@ -169,12 +169,12 @@ func TestOAuthBindLoginUnbind(t *testing.T) {
|
|||||||
t.Errorf("重复绑定 err = %v, want ErrOAuthBound", err)
|
t.Errorf("重复绑定 err = %v, want ErrOAuthBound", err)
|
||||||
}
|
}
|
||||||
// 已绑定身份可登录并拿到有效 JWT
|
// 已绑定身份可登录并拿到有效 JWT
|
||||||
token, display, err := o.loginByIdentity(ctx, "github", externalIdentity{Subject: "10086", Display: "octocat"})
|
token, loginUser, err := o.loginByIdentity(ctx, "github", externalIdentity{Subject: "10086", Display: "octocat"})
|
||||||
if err != nil || token == "" {
|
if err != nil || token == "" {
|
||||||
t.Fatalf("loginByIdentity: %v", err)
|
t.Fatalf("loginByIdentity: %v", err)
|
||||||
}
|
}
|
||||||
if display != "octocat" {
|
if loginUser != "admin" {
|
||||||
t.Errorf("display = %q", display)
|
t.Errorf("loginUser = %q, want admin", loginUser)
|
||||||
}
|
}
|
||||||
if username, err := auth.ParseToken(context.Background(), token); err != nil || username != "admin" {
|
if username, err := auth.ParseToken(context.Background(), token); err != nil || username != "admin" {
|
||||||
t.Errorf("token 应属 admin, got %q (%v)", username, err)
|
t.Errorf("token 应属 admin, got %q (%v)", username, err)
|
||||||
|
|||||||
Reference in New Issue
Block a user