Compare commits
10
Commits
v0.6.1
..
18e63d2dbd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18e63d2dbd | ||
|
|
91999205e2 | ||
|
|
cb66567256 | ||
|
|
d56678e1de | ||
|
|
8897c847a1 | ||
|
|
b2252678dd | ||
|
|
7002e42c06 | ||
|
|
da7b29d2e3 | ||
|
|
99b551401e | ||
|
|
4e2bab3032 |
@@ -20,6 +20,7 @@ Gin + GORM + SQLite(纯 Go 驱动 `glebarez/sqlite`,免 CGO;默认与推荐)+ AE
|
|||||||
| [Concurrency](./concurrency.md) | goroutine 生命周期与 context 传递 | 已填 |
|
| [Concurrency](./concurrency.md) | goroutine 生命周期与 context 传递 | 已填 |
|
||||||
| [Testing](./testing.md) | table-driven 测试要求 | 已填 |
|
| [Testing](./testing.md) | table-driven 测试要求 | 已填 |
|
||||||
| [Database Guidelines](./database-guidelines.md) | ORM 模式、查询、迁移 | 待填 |
|
| [Database Guidelines](./database-guidelines.md) | ORM 模式、查询、迁移 | 待填 |
|
||||||
|
| [OCI Audit](./oci-audit.md) | 审计事件双通道数据源、检索语义与预算纪律 | 已填 |
|
||||||
| [Logging Guidelines](./logging-guidelines.md) | 结构化日志、日志级别 | 待填 |
|
| [Logging Guidelines](./logging-guidelines.md) | 结构化日志、日志级别 | 待填 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# OCI 审计事件集成约定
|
||||||
|
|
||||||
|
> 2026-07 审计日志重构(数据源切换 + 检索 + 配额回退)沉淀;实现见 `internal/oci/audit.go`。
|
||||||
|
|
||||||
|
## 数据源:双通道,Search 主路 + Audit API 回退
|
||||||
|
|
||||||
|
- **Audit API(`audit.ListEvents`)无排序参数,窗口内固定按处理时间正序分页**。任何"从最新往更早"的列表需求禁止直接用它凑批——首批会拿到窗口内最旧的一段(2026-07-16 曾以此形态上线出 bug)。
|
||||||
|
- 倒序列表一律走 **Logging Search**(`loggingsearch.SearchLogs`,`search "<tenancy>/_Audit" | ... | sort by datetime desc`)。硬约束:单次查询时间窗 ≤ 14 天、limit ≤ 1000、时间过滤基于**处理时间**而非发生时间。
|
||||||
|
- **部分免费租户 Logging Search 服务配额为零**(报错含 `Rate limit exceeded` + `maxQueriesPerMinute: 0`,SDK 解析该错误体还会失败),属永久不可用,须自动回退 Audit API(小窗正序 + 前端全局重排);普通限流(配额非零)不回退。游标携带通道模式,续查不再试错。
|
||||||
|
|
||||||
|
## 检索语义
|
||||||
|
|
||||||
|
- `logContent = '*词*'` 是对整条日志 JSON **所有字段值**的包含匹配,会命中隐藏认证元数据(如 `opc-principal` 头里的 `ttype: login`),只可作服务端粗筛;**用户可见语义必须再做客户端精筛**(只匹配列表可见字段,不区分大小写,`*` 通配分段)。
|
||||||
|
- 用户输入进检索语句前必须消毒(去引号/反斜杠/控制字符、截断),见 `SanitizeAuditTerm`。
|
||||||
|
|
||||||
|
## 批式回溯的预算纪律
|
||||||
|
|
||||||
|
- 单批双预算:页数(`maxAuditPages`)+ 时间(`auditBatchTimeBudget`≈20s)。全文检索命中稀疏时大窗扫描单页可达十余秒,没有时间预算会出现 3 分钟级单请求。
|
||||||
|
- 空窗按倍增扩窗(上限受 14 天查询窗约束);响应回传 `scannedThrough` 供前端展示回溯进度,前端自动补批必须封顶,由用户显式继续。
|
||||||
@@ -2,6 +2,54 @@
|
|||||||
|
|
||||||
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。
|
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。
|
||||||
|
|
||||||
|
## [0.7.2]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- AI 网关设置新增「上游无响应预算」(`upstreamWaitSeconds`,30..900 秒,缺省 300,持久化、即时生效):非流式为单次尝试总超时,流式为等待响应头上限,供 multi-agent / 搜索类慢模型调宽
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 出站代理 Transport 补齐阶段超时(连接 30 秒 / TLS 握手 10 秒,对齐 SDK 直连模板):连不上的代理快速失败,不再拖满总超时
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 修复 Responses 直通调用 multi-agent / 搜索类模型必然超时:SDK 默认 `http.Client` 60 秒总超时覆盖到 body 读完,流式恰好 60 秒断流、非流式(响应头 >60 秒才返回)重试耗尽后约 121 秒报错。非流式改用预算总超时;流式去掉总超时,以定时取消模拟等待响应头预算,响应头到达后流时长不限、生命周期由客户端连接决定。附带消除此类超时对渠道熔断计数的误伤
|
||||||
|
|
||||||
|
## [0.7.1]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 审计事件接口新增检索参数 `q`:服务端 `logContent` 全文粗筛 + 可见字段(事件名 / 资源 / 操作者 / IP / 请求路径等)精筛,不区分大小写、支持 `*` 通配;关键字内嵌续查游标,跨批过滤口径一致。全文粗筛不作最终判定——`logContent` 会命中隐藏认证元数据(如 `opc-principal` 头里的 `ttype: login`)
|
||||||
|
- 审计批式响应新增 `scannedThrough`(已完整回溯到的时刻),供前端展示回溯进度
|
||||||
|
- Logging Search 服务配额为零的租户(报错含 `maxQueriesPerMinute: 0`,部分免费租户如此)自动回退 Audit API 小窗回溯:游标携带通道模式、续查不再试错,搜索降级为客户端可见字段匹配,审计页不再报错
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 审计事件数据源由 Audit API 切换为 Logging Search(`_Audit` 日志按 `datetime` 倒序,单页 200 条);空窗倍增上限由 30 天收紧到 14 天(单次查询时间窗硬限),单批新增约 20 秒时间预算,命中稀疏的深回溯拆成多个有界请求由前端接力
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 修复审计日志固定显示旧事件、刷新也看不到最新记录:Audit API 无排序参数且窗口内固定按处理时间正序,原实现凑满一批即返回,首批永远是 24h 窗口内最旧的一段,「向更早加载」实际在向更新方向翻页
|
||||||
|
|
||||||
|
## [0.7.0]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- AI 网关运行时设置扩展(`GET` / `PUT /api/v1/ai-settings`,持久化、即时生效):
|
||||||
|
- Responses 流式保险丝:开关 + 阈值(KB,1..1024),超阈值的流式请求预防性改非流式上游并合成最小 SSE
|
||||||
|
- grok 服务端搜索工具默认注入:`xai.` 前缀模型的 Responses 请求按开关默认注入 `web_search` / `x_search`,请求 `tools` 已含同名工具(任意参数形态)时不覆盖,注入动作记服务端日志
|
||||||
|
- 聚合模型目录端点 `GET /api/v1/ai-model-catalog`:启用渠道去重、含能力字段(空能力归一 CHAT),与模型列表同口径(随「过滤弃用」开关),供设置页黑名单添加弹窗使用
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Responses 流式保险丝触发口径由「完整请求体 > 76KB(环境变量 `AI_RESP_STREAM_UPGRADE_KB`)」改为「`instructions` + `tools` 原始字节合计超阈值(默认开、60KB,设置页可调)」:复测证实约 82KB 的纯体积断流已由上游修复,而 `instructions`+`tools` 合计 >≈64.5KB 的流式静默断流仍存在(`input` 不计入);环境变量随之废弃
|
||||||
|
- 升级 oci-go-sdk 到 v65.121.0
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 修复多网卡实例的列表 / 详情地址字段随机:并发填充改以主网卡为准(主卡未返回前允许先到网卡兜底,主卡到达后覆盖并锁定),公网 IP / 私网 IP / IPv6 / 子网恒为主网卡数据
|
||||||
|
|
||||||
## [0.6.1]
|
## [0.6.1]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
v0.6.0
|
v0.7.2
|
||||||
|
|||||||
@@ -6,12 +6,14 @@
|
|||||||
|
|
||||||
**自托管的 OCI 多租户管理面板与 GenAI 兼容网关**
|
**自托管的 OCI 多租户管理面板与 GenAI 兼容网关**
|
||||||
|
|
||||||

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

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

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

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

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
[快速接入](#quick-start) · [端点一览](#endpoints) · [路由机制](#routing) · [Codex 接入](#codex) · [已知限制](#limitations) · [兼容矩阵](#compatibility)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> OCI Portal AI 网关将多个 OCI GenAI 渠道统一为 OpenAI、Anthropic 与 xAI
|
> 网关集中处理密钥鉴权、模型访问控制、渠道调度与协议适配。路径、参数和
|
||||||
> 兼容接口,并集中处理鉴权、模型访问控制、渠道调度和协议适配。
|
> 响应结构以 [Swagger YAML](swagger.yaml) 或运行时 Swagger UI 为准;协议差异、
|
||||||
>
|
> 兼容改写和实测边界以本文为准。
|
||||||
> 路径、参数与响应结构以 [Swagger YAML](swagger.yaml) 或运行时 Swagger UI
|
|
||||||
> 为准;协议差异、兼容改写和实测边界以本文为准。
|
|
||||||
|
|
||||||
**兼容快照:2026-07-15**
|
| 文档属性 | 当前值 |
|
||||||
|
| --- | --- |
|
||||||
## 快速导航
|
| 兼容快照 | **2026-07-16** |
|
||||||
|
| API 基址 | `/ai/v1` |
|
||||||
- [快速接入](#quick-start)
|
| 首选对话协议 | OpenAI Responses |
|
||||||
- [端点一览](#endpoints)
|
| 会话模式 | 无状态,客户端携带完整上下文 |
|
||||||
- [路由与全局行为](#routing)
|
|
||||||
- [Codex 接入](#codex)
|
|
||||||
- [已知限制](#limitations)
|
|
||||||
- [字段兼容矩阵](#compatibility)
|
|
||||||
- [实现索引](#implementation)
|
|
||||||
|
|
||||||
<a id="quick-start"></a>
|
<a id="quick-start"></a>
|
||||||
|
|
||||||
@@ -25,19 +35,13 @@
|
|||||||
|
|
||||||
### 基础地址与鉴权
|
### 基础地址与鉴权
|
||||||
|
|
||||||
```text
|
网关密钥在管理面板中创建。连接信息如下:
|
||||||
Base URL: https://<网关地址>/ai/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
网关密钥在管理面板中创建,支持以下任一请求头:
|
| 项目 | 配置 |
|
||||||
|
| --- | --- |
|
||||||
```http
|
| Base URL | `https://<网关地址>/ai/v1` |
|
||||||
Authorization: Bearer sk-...
|
| Bearer 鉴权 | `Authorization: Bearer sk-...` |
|
||||||
```
|
| API Key 鉴权 | `x-api-key: sk-...` |
|
||||||
|
|
||||||
```http
|
|
||||||
x-api-key: sk-...
|
|
||||||
```
|
|
||||||
|
|
||||||
密钥可绑定渠道分组和模型白名单。全局模型黑名单会同时作用于模型列表、
|
密钥可绑定渠道分组和模型白名单。全局模型黑名单会同时作用于模型列表、
|
||||||
请求路由和探测候选;开启「过滤弃用模型」后,OCI 已宣布弃用的模型也会从
|
请求路由和探测候选;开启「过滤弃用模型」后,OCI 已宣布弃用的模型也会从
|
||||||
@@ -50,6 +54,15 @@ curl "https://<网关地址>/ai/v1/models" \
|
|||||||
-H "Authorization: Bearer $OCI_PORTAL_KEY"
|
-H "Authorization: Bearer $OCI_PORTAL_KEY"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
再发起一条最小 Responses 请求;请将示例模型替换为模型列表中的可见模型:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://<网关地址>/ai/v1/responses" \
|
||||||
|
-H "Authorization: Bearer $OCI_PORTAL_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"model":"xai.grok-4.3","input":"你好,请用一句话介绍自己。"}'
|
||||||
|
```
|
||||||
|
|
||||||
<a id="endpoints"></a>
|
<a id="endpoints"></a>
|
||||||
|
|
||||||
## 端点一览
|
## 端点一览
|
||||||
@@ -66,12 +79,14 @@ curl "https://<网关地址>/ai/v1/models" \
|
|||||||
| 安全 | `POST /ai/v1/moderations` | OpenAI 外壳映射 OCI Guardrails | — |
|
| 安全 | `POST /ai/v1/moderations` | OpenAI 外壳映射 OCI Guardrails | — |
|
||||||
| 发现 | `GET /ai/v1/models` | 当前密钥可见模型列表 | — |
|
| 发现 | `GET /ai/v1/models` | 当前密钥可见模型列表 | — |
|
||||||
|
|
||||||
选择建议:
|
### 如何选择协议
|
||||||
|
|
||||||
- 新客户端优先使用 **Responses**
|
| 使用场景 | 推荐接口 |
|
||||||
- Anthropic SDK 或 Claude 生态客户端使用 **Messages**
|
| --- | --- |
|
||||||
- 仅支持旧 OpenAI 对话协议的客户端使用 **Chat Completions**
|
| 新客户端、推理模型、服务端工具 | **Responses** |
|
||||||
- Embeddings、TTS、Rerank 与 Moderations 使用各自专用端点
|
| Anthropic SDK、Claude 生态客户端 | **Messages** |
|
||||||
|
| 仅支持旧 OpenAI 对话协议的客户端 | **Chat Completions** |
|
||||||
|
| 向量、语音、重排与安全审核 | 对应专用端点 |
|
||||||
|
|
||||||
<a id="routing"></a>
|
<a id="routing"></a>
|
||||||
|
|
||||||
@@ -106,6 +121,14 @@ flowchart LR
|
|||||||
| 文件输入 | `input_file` 会被 OCI ZDR 形态拒绝,详见[已知限制](#limitations) |
|
| 文件输入 | `input_file` 会被 OCI ZDR 形态拒绝,详见[已知限制](#limitations) |
|
||||||
| Chat 定位 | Chat Completions 只承担协议转换与兼容修复;新能力优先落在 Responses 与 Messages |
|
| Chat 定位 | Chat Completions 只承担协议转换与兼容修复;新能力优先落在 Responses 与 Messages |
|
||||||
|
|
||||||
|
### 可配置运行策略
|
||||||
|
|
||||||
|
| 策略 | 默认行为 | 配置入口 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Responses 流式保险丝 | 开启;阈值 `60 KB`,按 `instructions` 与 `tools` 两个字段的原始 JSON 值计算 | **设置 → AI → 流式保险丝** |
|
||||||
|
| Grok 服务端搜索 | 对 `xai.` 模型默认注入 `web_search` 与 `x_search`;同名工具不重复覆盖 | **设置 → AI → Grok 服务端搜索工具** |
|
||||||
|
| 弃用模型过滤 | 开启后从模型列表、请求路由与探测候选中统一排除 | **设置 → AI → 模型治理** |
|
||||||
|
|
||||||
<a id="codex"></a>
|
<a id="codex"></a>
|
||||||
|
|
||||||
## Codex 接入
|
## Codex 接入
|
||||||
@@ -143,7 +166,7 @@ model = "xai.grok-4.3"
|
|||||||
```
|
```
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> `codex-cli 0.144.1` 已实测主会话和 multi-agent 子代理全链路可用。
|
> `Codex CLI 0.144.1` 已实测主会话和 multi-agent 子代理全链路可用。
|
||||||
> 内置 worker 可能先尝试 `gpt-5.6-luna` 或 `gpt-5.4`;网关没有对应渠道时
|
> 内置 worker 可能先尝试 `gpt-5.6-luna` 或 `gpt-5.4`;网关没有对应渠道时
|
||||||
> 会出现少量 404,随后由 Codex 回落到可用模型。自定义 agent 的模型覆盖是否
|
> 会出现少量 404,随后由 Codex 回落到可用模型。自定义 agent 的模型覆盖是否
|
||||||
> 直接生效取决于 Codex 版本;0.144.1 的实测主要依赖自动回落。
|
> 直接生效取决于 Codex 版本;0.144.1 的实测主要依赖自动回落。
|
||||||
@@ -165,22 +188,35 @@ Codex 工具兼容现状:
|
|||||||
|
|
||||||
## 已知限制
|
## 已知限制
|
||||||
|
|
||||||
### 超大流式请求
|
### `instructions` / `tools` 大体量流式断流
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> 2026-07 实测:OCI xAI 兼容面对**完整请求体超过约 82 KB** 的流式请求,
|
> `instructions` 与 `tools` 两个字段的原始 JSON 值合计超过约 **64.5 KB** 时,
|
||||||
> 可能在推理阶段提前断流;该现象由请求体积触发,与工具构成或字符集无关,
|
> 上游流式请求可能在推理阶段静默断连:连接直接 EOF,不发送 `error` 或终态事件。
|
||||||
> 同一请求改为非流式可正常完成。
|
> 同一请求改为非流式实测可正常完成;单独扩大 `input` 未触发该限制。
|
||||||
|
|
||||||
网关按协议采取不同保护:
|
该问题于 2026-07-13 通过字节级二分定位,2026-07-16 在 Chicago 复测仍存在:
|
||||||
|
`70.4 KB` 断流、`59.7 KB` 正常,API Key 与签名鉴权表现一致。本文及设置页中的
|
||||||
|
`KB` 均按 `1024 B` 计算。
|
||||||
|
|
||||||
- **Responses**:兼容改写后的请求体超过 **76 KB** 时,预防性改为非流式上游
|
| 协议 | 网关保护 | 客户端表现 |
|
||||||
请求,再合成最小 SSE 序列(`response.created` →
|
| --- | --- | --- |
|
||||||
`response.output_item.done` → `response.completed`);结果语义保留,但没有增量输出
|
| Responses | 保险丝默认开启;超过 `60 KB` 时改走非流式上游,并合成最小 SSE 序列 | 结果语义保留,但不再增量输出 |
|
||||||
- **Chat Completions / Messages**:若上游在客户端收到任何内容前断流,自动用
|
| Chat Completions / Messages | 客户端尚未收到内容就断流时,自动改用非流式重做 | 合成对应 chunk / event 序列 |
|
||||||
非流式重做,并合成对应 chunk / event 序列
|
| 已开始输出的流 | 无法透明重试;调用日志记录提前终止 | 客户端可能只收到部分事件 |
|
||||||
- **已开始输出的流**:不能透明重试;调用日志会记录提前终止,客户端可能只拿到
|
|
||||||
部分事件
|
Responses 合成的最小事件序列为:`response.created` →
|
||||||
|
`response.output_item.done` → `response.completed`。保险丝可在
|
||||||
|
**设置 → AI → 流式保险丝** 调整或关闭。
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>历史问题:完整请求体体积断流(已由上游修复)</strong></summary>
|
||||||
|
|
||||||
|
2026-07-15 曾在完整请求体约 `82 KB`(含 `input`)时观测到纯体积流式断流。
|
||||||
|
2026-07-16 复核 `83 KB`、真实 Codex 形态 `104.5 KB`、`200 KB` 与 `400 KB`
|
||||||
|
请求均正常完成;Chicago 与 Phoenix、签名与 API Key 两条路径结果一致。
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
### ZDR 与文件输入
|
### ZDR 与文件输入
|
||||||
|
|
||||||
@@ -192,16 +228,20 @@ File content is currently unsupported for ZDR customers
|
|||||||
|
|
||||||
网关强制 `store:false`,属于 ZDR 请求形态,因此当前不能通过该端点上传或引用文件。
|
网关强制 `store:false`,属于 ZDR 请求形态,因此当前不能通过该端点上传或引用文件。
|
||||||
|
|
||||||
### 规格与实测边界
|
|
||||||
|
|
||||||
本项目提供的是兼容接口,而不是 OpenAI、Anthropic 或 xAI 协议的完整实现。
|
|
||||||
部分 OCI OpenAI 兼容行为来自实测,未见 Oracle 文档合同,可能随上游调整。
|
|
||||||
|
|
||||||
<a id="compatibility"></a>
|
<a id="compatibility"></a>
|
||||||
|
|
||||||
## 字段兼容矩阵
|
## 字段兼容矩阵
|
||||||
|
|
||||||
矩阵基线:
|
> [!IMPORTANT]
|
||||||
|
> 本项目提供兼容接口,而不是 OpenAI、Anthropic 或 xAI 协议的完整实现。部分 OCI
|
||||||
|
> OpenAI 兼容行为来自实测,未见 Oracle 文档合同,可能随上游调整。
|
||||||
|
|
||||||
|
### 矩阵索引
|
||||||
|
|
||||||
|
[Responses](#compat-responses) · [Chat Completions](#compat-chat) · [Messages](#compat-messages) · [Embeddings](#compat-embeddings) · [语音生成](#compat-audio) · [Rerank](#compat-rerank) · [Moderations](#compat-moderations) · [Models](#compat-models)
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>展开参考规格</strong></summary>
|
||||||
|
|
||||||
- [OpenAI Responses](https://developers.openai.com/api/reference/resources/responses/methods/create)
|
- [OpenAI Responses](https://developers.openai.com/api/reference/resources/responses/methods/create)
|
||||||
- [OpenAI Chat Completions](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create)
|
- [OpenAI Chat Completions](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create)
|
||||||
@@ -211,6 +251,8 @@ File content is currently unsupported for ZDR customers
|
|||||||
- [Anthropic Messages](https://platform.claude.com/docs/en/api/messages/create)
|
- [Anthropic Messages](https://platform.claude.com/docs/en/api/messages/create)
|
||||||
- [Cohere Rerank](https://docs.cohere.com/reference/rerank)
|
- [Cohere Rerank](https://docs.cohere.com/reference/rerank)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
| 标记 | 含义 |
|
| 标记 | 含义 |
|
||||||
| :---: | --- |
|
| :---: | --- |
|
||||||
| ✅ | 网关直接支持 |
|
| ✅ | 网关直接支持 |
|
||||||
@@ -218,6 +260,8 @@ File content is currently unsupported for ZDR customers
|
|||||||
| 🔄 | 网关执行字段或协议转换后支持 |
|
| 🔄 | 网关执行字段或协议转换后支持 |
|
||||||
| ◐ | 部分支持、存在前置条件或语义降级 |
|
| ◐ | 部分支持、存在前置条件或语义降级 |
|
||||||
|
|
||||||
|
<a id="compat-responses"></a>
|
||||||
|
|
||||||
### OpenAI Responses
|
### OpenAI Responses
|
||||||
|
|
||||||
**`POST /ai/v1/responses` · 无状态主接口**
|
**`POST /ai/v1/responses` · 无状态主接口**
|
||||||
@@ -239,12 +283,12 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
| `reasoning` | ➡️ | 整个对象保留;`effort` 不校验档位 |
|
| `reasoning` | ➡️ | 整个对象保留;`effort` 不校验档位 |
|
||||||
| `tool_choice` | ◐ | 普通形态保留;namespace 对象会重限定,全部工具被剥离时删除 |
|
| `tool_choice` | ◐ | 普通形态保留;namespace 对象会重限定,全部工具被剥离时删除 |
|
||||||
| `store` | 🔄 | 无论客户端传什么,上游请求都强制改写为 `false` |
|
| `store` | 🔄 | 无论客户端传什么,上游请求都强制改写为 `false` |
|
||||||
| `stream` | ◐ | 支持 SSE;请求体超过 76 KB 时改为非流式上游并合成 SSE |
|
| `stream` | ◐ | 支持 SSE;`instructions` 与 `tools` 的原始 JSON 值合计超过保险丝阈值时,预防性改走非流式上游(默认开启、`60 KB`,见[已知限制](#limitations)) |
|
||||||
| 其余标准与未知顶层字段 | ➡️ | `context_management`、`include`、`metadata`、`prompt`、`prompt_cache_key`、`service_tier`、`truncation`、`user` 等均保留,由 OCI 决定是否接受 |
|
| 其余标准与未知顶层字段 | ➡️ | `context_management`、`include`、`metadata`、`prompt`、`prompt_cache_key`、`service_tier`、`truncation`、`user` 等均保留,由 OCI 决定是否接受 |
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details open>
|
<details>
|
||||||
<summary><strong>工具兼容</strong></summary>
|
<summary><strong>工具兼容</strong></summary>
|
||||||
|
|
||||||
| 工具或参数 | 状态 | 网关行为 |
|
| 工具或参数 | 状态 | 网关行为 |
|
||||||
@@ -254,7 +298,7 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
| `tools[].type=mcp` | ➡️ | 远程 MCP 由上游直连,`server_url`、`require_approval`、`authorization` 等保留 |
|
| `tools[].type=mcp` | ➡️ | 远程 MCP 由上游直连,`server_url`、`require_approval`、`authorization` 等保留 |
|
||||||
| `tools[].type=namespace` | 🔄 | 子 `function` 上提并限定命名;响应、历史调用和 `tool_choice` 会反向还原;组内非 `function` 子工具被剥离 |
|
| `tools[].type=namespace` | 🔄 | 子 `function` 上提并限定命名;响应、历史调用和 `tool_choice` 会反向还原;组内非 `function` 子工具被剥离 |
|
||||||
| `tools[].type=custom` | ◐ | 顶层非 `apply_patch` 工具转为带 `input` schema 的 `function`;响应与多轮历史双向回转;`format` 会删除 |
|
| `tools[].type=custom` | ◐ | 顶层非 `apply_patch` 工具转为带 `input` schema 的 `function`;响应与多轮历史双向回转;`format` 会删除 |
|
||||||
| `custom:apply_patch` | ◐ | 整体剥离;grok 系未针对 Codex 补丁格式训练,模型应回落其他编辑方式 |
|
| `custom:apply_patch` | ◐ | 整体剥离;Grok 系未针对 Codex 补丁格式训练,模型应回落其他编辑方式 |
|
||||||
| `tools[].type=tool_search` | ◐ | 请求可被接受,但工具本身直接剥离 |
|
| `tools[].type=tool_search` | ◐ | 请求可被接受,但工具本身直接剥离 |
|
||||||
| `web_search.external_web_access=true` | 🔄 | 删除上游不识别的字段,保留 `web_search` |
|
| `web_search.external_web_access=true` | 🔄 | 删除上游不识别的字段,保留 `web_search` |
|
||||||
| `web_search.external_web_access=false` | ◐ | 上游没有“仅缓存检索”对应能力,按不越权原则剥离整个工具 |
|
| `web_search.external_web_access=false` | ◐ | 上游没有“仅缓存检索”对应能力,按不越权原则剥离整个工具 |
|
||||||
@@ -277,6 +321,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
- 未知模型返回 404,无可用渠道返回 503
|
- 未知模型返回 404,无可用渠道返回 503
|
||||||
- 上游错误使用 OpenAI 风格错误外壳,但不保证字段与标准 OpenAI 完全一致
|
- 上游错误使用 OpenAI 风格错误外壳,但不保证字段与标准 OpenAI 完全一致
|
||||||
|
|
||||||
|
<a id="compat-chat"></a>
|
||||||
|
|
||||||
### OpenAI Chat Completions
|
### OpenAI Chat Completions
|
||||||
|
|
||||||
**`POST /ai/v1/chat/completions` · 存量客户端兼容层**
|
**`POST /ai/v1/chat/completions` · 存量客户端兼容层**
|
||||||
@@ -332,6 +378,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
`prompt_tokens_details.cached_tokens`
|
`prompt_tokens_details.cached_tokens`
|
||||||
- 已开始输出的流中断不会转换成标准 SSE 错误事件
|
- 已开始输出的流中断不会转换成标准 SSE 错误事件
|
||||||
|
|
||||||
|
<a id="compat-messages"></a>
|
||||||
|
|
||||||
### Anthropic Messages
|
### Anthropic Messages
|
||||||
|
|
||||||
**`POST /ai/v1/messages` · Anthropic 协议转换层**
|
**`POST /ai/v1/messages` · Anthropic 协议转换层**
|
||||||
@@ -385,6 +433,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
- usage 只保留 `input_tokens`、`output_tokens` 与 `cache_read_input_tokens`
|
- usage 只保留 `input_tokens`、`output_tokens` 与 `cache_read_input_tokens`
|
||||||
- 流式上游错误与无终态断流会转成 Anthropic `error` 事件
|
- 流式上游错误与无终态断流会转成 Anthropic `error` 事件
|
||||||
|
|
||||||
|
<a id="compat-embeddings"></a>
|
||||||
|
|
||||||
### OpenAI Embeddings
|
### OpenAI Embeddings
|
||||||
|
|
||||||
**`POST /ai/v1/embeddings` · 向量化专用端点**
|
**`POST /ai/v1/embeddings` · 向量化专用端点**
|
||||||
@@ -402,6 +452,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
|
|
||||||
响应使用标准 `object:"list"` 外壳,向量为 `float32` 数组,不支持流式。
|
响应使用标准 `object:"list"` 外壳,向量为 `float32` 数组,不支持流式。
|
||||||
|
|
||||||
|
<a id="compat-audio"></a>
|
||||||
|
|
||||||
### 语音生成
|
### 语音生成
|
||||||
|
|
||||||
两个端点最终使用同一 OCI xAI TTS 上游与渠道调度:
|
两个端点最终使用同一 OCI xAI TTS 上游与渠道调度:
|
||||||
@@ -442,6 +494,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
- 不提供 HTTP 流式音频或 WebSocket 代理
|
- 不提供 HTTP 流式音频或 WebSocket 代理
|
||||||
- 无 token 用量口径,调用日志只记时延与渠道
|
- 无 token 用量口径,调用日志只记时延与渠道
|
||||||
|
|
||||||
|
<a id="compat-rerank"></a>
|
||||||
|
|
||||||
### Rerank
|
### Rerank
|
||||||
|
|
||||||
**`POST /ai/v1/rerank` · Cohere / Jina 风格协议**
|
**`POST /ai/v1/rerank` · Cohere / Jina 风格协议**
|
||||||
@@ -459,6 +513,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
`max_tokens_per_doc` 与未知字段静默忽略。响应按相关度降序,
|
`max_tokens_per_doc` 与未知字段静默忽略。响应按相关度降序,
|
||||||
`results[].index` 指向输入下标,`relevance_score` 为 0~1 浮点;无 token 用量口径。
|
`results[].index` 指向输入下标,`relevance_score` 为 0~1 浮点;无 token 用量口径。
|
||||||
|
|
||||||
|
<a id="compat-moderations"></a>
|
||||||
|
|
||||||
### Moderations
|
### Moderations
|
||||||
|
|
||||||
**`POST /ai/v1/moderations` · OpenAI 外壳映射 OCI Guardrails**
|
**`POST /ai/v1/moderations` · OpenAI 外壳映射 OCI Guardrails**
|
||||||
@@ -480,6 +536,8 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
- 实测中文人名与手机号识别较弱,英文 PII 识别正常
|
- 实测中文人名与手机号识别较弱,英文 PII 识别正常
|
||||||
- 响应 `model` 恒为 `oci-guardrails`
|
- 响应 `model` 恒为 `oci-guardrails`
|
||||||
|
|
||||||
|
<a id="compat-models"></a>
|
||||||
|
|
||||||
### Models
|
### Models
|
||||||
|
|
||||||
**`GET /ai/v1/models` · 当前密钥可见模型列表**
|
**`GET /ai/v1/models` · 当前密钥可见模型列表**
|
||||||
@@ -496,7 +554,7 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
|
|
||||||
<a id="implementation"></a>
|
<a id="implementation"></a>
|
||||||
|
|
||||||
## 实现索引
|
## 附录:实现索引
|
||||||
|
|
||||||
| 端点 / 能力 | 主要实现 |
|
| 端点 / 能力 | 主要实现 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -510,3 +568,5 @@ Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转
|
|||||||
|
|
||||||
本文是兼容性快照,不替代 Swagger。标准接口、Codex 客户端与 OCI 上游均可能
|
本文是兼容性快照,不替代 Swagger。标准接口、Codex 客户端与 OCI 上游均可能
|
||||||
变化,最终行为以当前版本代码、运行时 Swagger 和实测结果为准。
|
变化,最终行为以当前版本代码、运行时 Swagger 和实测结果为准。
|
||||||
|
|
||||||
|
[返回顶部](#top)
|
||||||
|
|||||||
+81
-1
@@ -786,6 +786,27 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/ai-model-catalog": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"AI 管理"
|
||||||
|
],
|
||||||
|
"summary": "聚合模型目录",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/internal_api.itemsResponse-oci-portal_internal_service_AggregatedModel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/ai-models": {
|
"/api/v1/ai-models": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -839,7 +860,7 @@ const docTemplate = `{
|
|||||||
"summary": "更新 AI 网关全局设置",
|
"summary": "更新 AI 网关全局设置",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"description": "开启后已宣布弃用(即使未退役)的模型从列表与路由中排除",
|
"description": "全量提交;保险丝阈值限 1..1024 KB,上游无响应预算限 30..900 秒",
|
||||||
"name": "body",
|
"name": "body",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -854,6 +875,15 @@ const docTemplate = `{
|
|||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/internal_api.aiSettingsResponse"
|
"$ref": "#/definitions/internal_api.aiSettingsResponse"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1542,6 +1572,12 @@ const docTemplate = `{
|
|||||||
"description": "单批目标条数,缺省 100,上限 200",
|
"description": "单批目标条数,缺省 100,上限 200",
|
||||||
"name": "limit",
|
"name": "limit",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "检索关键字(服务端全文匹配,支持 * 通配;仅首查生效)",
|
||||||
|
"name": "q",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -6050,7 +6086,26 @@ const docTemplate = `{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"filterDeprecated": {
|
"filterDeprecated": {
|
||||||
|
"description": "FilterDeprecated 开启后已宣布弃用(即使未退役)的模型从列表与路由中排除",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"grokWebSearch": {
|
||||||
|
"description": "GrokWebSearch / GrokXSearch 是 xai. 模型服务端搜索工具默认注入开关;\n请求 tools 已包含同名工具时不覆盖",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"grokXSearch": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"streamGuardEnabled": {
|
||||||
|
"description": "StreamGuardEnabled / StreamGuardKB 是 Responses 流式保险丝:\ninstructions+tools 合计超阈值(KB)的流式请求改非流式上游并合成 SSE",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"streamGuardKB": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"upstreamWaitSeconds": {
|
||||||
|
"description": "UpstreamWaitSeconds 是 responses 直通的上游无响应预算(秒):非流式为单次\n尝试总超时,流式为等待响应头预算;multi-agent/搜索类模型需远超 60s",
|
||||||
|
"type": "integer"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -6621,6 +6676,17 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"internal_api.itemsResponse-oci-portal_internal_service_AggregatedModel": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/oci-portal_internal_service.AggregatedModel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"internal_api.itemsResponse-oci-portal_internal_service_NotifyChannelView": {
|
"internal_api.itemsResponse-oci-portal_internal_service_NotifyChannelView": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -9358,6 +9424,17 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"oci-portal_internal_service.AggregatedModel": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"capability": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"oci-portal_internal_service.AuditEventsView": {
|
"oci-portal_internal_service.AuditEventsView": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -9372,6 +9449,9 @@ const docTemplate = `{
|
|||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/definitions/oci-portal_internal_oci.AuditEvent"
|
"$ref": "#/definitions/oci-portal_internal_oci.AuditEvent"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"scannedThrough": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+81
-1
@@ -779,6 +779,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/ai-model-catalog": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"AI 管理"
|
||||||
|
],
|
||||||
|
"summary": "聚合模型目录",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/internal_api.itemsResponse-oci-portal_internal_service_AggregatedModel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/ai-models": {
|
"/api/v1/ai-models": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -832,7 +853,7 @@
|
|||||||
"summary": "更新 AI 网关全局设置",
|
"summary": "更新 AI 网关全局设置",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"description": "开启后已宣布弃用(即使未退役)的模型从列表与路由中排除",
|
"description": "全量提交;保险丝阈值限 1..1024 KB,上游无响应预算限 30..900 秒",
|
||||||
"name": "body",
|
"name": "body",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -847,6 +868,15 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/internal_api.aiSettingsResponse"
|
"$ref": "#/definitions/internal_api.aiSettingsResponse"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1535,6 +1565,12 @@
|
|||||||
"description": "单批目标条数,缺省 100,上限 200",
|
"description": "单批目标条数,缺省 100,上限 200",
|
||||||
"name": "limit",
|
"name": "limit",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "检索关键字(服务端全文匹配,支持 * 通配;仅首查生效)",
|
||||||
|
"name": "q",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -6043,7 +6079,26 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"filterDeprecated": {
|
"filterDeprecated": {
|
||||||
|
"description": "FilterDeprecated 开启后已宣布弃用(即使未退役)的模型从列表与路由中排除",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"grokWebSearch": {
|
||||||
|
"description": "GrokWebSearch / GrokXSearch 是 xai. 模型服务端搜索工具默认注入开关;\n请求 tools 已包含同名工具时不覆盖",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"grokXSearch": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"streamGuardEnabled": {
|
||||||
|
"description": "StreamGuardEnabled / StreamGuardKB 是 Responses 流式保险丝:\ninstructions+tools 合计超阈值(KB)的流式请求改非流式上游并合成 SSE",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"streamGuardKB": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"upstreamWaitSeconds": {
|
||||||
|
"description": "UpstreamWaitSeconds 是 responses 直通的上游无响应预算(秒):非流式为单次\n尝试总超时,流式为等待响应头预算;multi-agent/搜索类模型需远超 60s",
|
||||||
|
"type": "integer"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -6614,6 +6669,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"internal_api.itemsResponse-oci-portal_internal_service_AggregatedModel": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/oci-portal_internal_service.AggregatedModel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"internal_api.itemsResponse-oci-portal_internal_service_NotifyChannelView": {
|
"internal_api.itemsResponse-oci-portal_internal_service_NotifyChannelView": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -9351,6 +9417,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"oci-portal_internal_service.AggregatedModel": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"capability": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"oci-portal_internal_service.AuditEventsView": {
|
"oci-portal_internal_service.AuditEventsView": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -9365,6 +9442,9 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/definitions/oci-portal_internal_oci.AuditEvent"
|
"$ref": "#/definitions/oci-portal_internal_oci.AuditEvent"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"scannedThrough": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+59
-1
@@ -49,7 +49,27 @@ definitions:
|
|||||||
internal_api.aiSettingsResponse:
|
internal_api.aiSettingsResponse:
|
||||||
properties:
|
properties:
|
||||||
filterDeprecated:
|
filterDeprecated:
|
||||||
|
description: FilterDeprecated 开启后已宣布弃用(即使未退役)的模型从列表与路由中排除
|
||||||
type: boolean
|
type: boolean
|
||||||
|
grokWebSearch:
|
||||||
|
description: |-
|
||||||
|
GrokWebSearch / GrokXSearch 是 xai. 模型服务端搜索工具默认注入开关;
|
||||||
|
请求 tools 已包含同名工具时不覆盖
|
||||||
|
type: boolean
|
||||||
|
grokXSearch:
|
||||||
|
type: boolean
|
||||||
|
streamGuardEnabled:
|
||||||
|
description: |-
|
||||||
|
StreamGuardEnabled / StreamGuardKB 是 Responses 流式保险丝:
|
||||||
|
instructions+tools 合计超阈值(KB)的流式请求改非流式上游并合成 SSE
|
||||||
|
type: boolean
|
||||||
|
streamGuardKB:
|
||||||
|
type: integer
|
||||||
|
upstreamWaitSeconds:
|
||||||
|
description: |-
|
||||||
|
UpstreamWaitSeconds 是 responses 直通的上游无响应预算(秒):非流式为单次
|
||||||
|
尝试总超时,流式为等待响应头预算;multi-agent/搜索类模型需远超 60s
|
||||||
|
type: integer
|
||||||
type: object
|
type: object
|
||||||
internal_api.attachBootVolumeRequest:
|
internal_api.attachBootVolumeRequest:
|
||||||
properties:
|
properties:
|
||||||
@@ -427,6 +447,13 @@ definitions:
|
|||||||
$ref: '#/definitions/oci-portal_internal_model.UserIdentity'
|
$ref: '#/definitions/oci-portal_internal_model.UserIdentity'
|
||||||
type: array
|
type: array
|
||||||
type: object
|
type: object
|
||||||
|
internal_api.itemsResponse-oci-portal_internal_service_AggregatedModel:
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/oci-portal_internal_service.AggregatedModel'
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
internal_api.itemsResponse-oci-portal_internal_service_NotifyChannelView:
|
internal_api.itemsResponse-oci-portal_internal_service_NotifyChannelView:
|
||||||
properties:
|
properties:
|
||||||
items:
|
items:
|
||||||
@@ -2232,6 +2259,13 @@ definitions:
|
|||||||
description: 卷本身的名称(attachment 名是自动生成的)
|
description: 卷本身的名称(attachment 名是自动生成的)
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
oci-portal_internal_service.AggregatedModel:
|
||||||
|
properties:
|
||||||
|
capability:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
oci-portal_internal_service.AuditEventsView:
|
oci-portal_internal_service.AuditEventsView:
|
||||||
properties:
|
properties:
|
||||||
cursor:
|
cursor:
|
||||||
@@ -2242,6 +2276,8 @@ definitions:
|
|||||||
items:
|
items:
|
||||||
$ref: '#/definitions/oci-portal_internal_oci.AuditEvent'
|
$ref: '#/definitions/oci-portal_internal_oci.AuditEvent'
|
||||||
type: array
|
type: array
|
||||||
|
scannedThrough:
|
||||||
|
type: string
|
||||||
type: object
|
type: object
|
||||||
oci-portal_internal_service.Changes:
|
oci-portal_internal_service.Changes:
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
@@ -3135,6 +3171,18 @@ paths:
|
|||||||
summary: AI 调用日志列表
|
summary: AI 调用日志列表
|
||||||
tags:
|
tags:
|
||||||
- AI 管理
|
- AI 管理
|
||||||
|
/api/v1/ai-model-catalog:
|
||||||
|
get:
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/internal_api.itemsResponse-oci-portal_internal_service_AggregatedModel'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 聚合模型目录
|
||||||
|
tags:
|
||||||
|
- AI 管理
|
||||||
/api/v1/ai-models:
|
/api/v1/ai-models:
|
||||||
get:
|
get:
|
||||||
responses:
|
responses:
|
||||||
@@ -3161,7 +3209,7 @@ paths:
|
|||||||
- AI 管理
|
- AI 管理
|
||||||
put:
|
put:
|
||||||
parameters:
|
parameters:
|
||||||
- description: 开启后已宣布弃用(即使未退役)的模型从列表与路由中排除
|
- description: 全量提交;保险丝阈值限 1..1024 KB,上游无响应预算限 30..900 秒
|
||||||
in: body
|
in: body
|
||||||
name: body
|
name: body
|
||||||
required: true
|
required: true
|
||||||
@@ -3172,6 +3220,12 @@ paths:
|
|||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/internal_api.aiSettingsResponse'
|
$ref: '#/definitions/internal_api.aiSettingsResponse'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
security:
|
security:
|
||||||
- BearerAuth: []
|
- BearerAuth: []
|
||||||
summary: 更新 AI 网关全局设置
|
summary: 更新 AI 网关全局设置
|
||||||
@@ -3590,6 +3644,10 @@ paths:
|
|||||||
in: query
|
in: query
|
||||||
name: limit
|
name: limit
|
||||||
type: integer
|
type: integer
|
||||||
|
- description: 检索关键字(服务端全文匹配,支持 * 通配;仅首查生效)
|
||||||
|
in: query
|
||||||
|
name: q
|
||||||
|
type: string
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ require (
|
|||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/oracle/oci-go-sdk/v65 v65.120.0
|
github.com/oracle/oci-go-sdk/v65 v65.121.0
|
||||||
github.com/pquerna/otp v1.5.0
|
github.com/pquerna/otp v1.5.0
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/swaggo/files v1.0.1
|
github.com/swaggo/files v1.0.1
|
||||||
|
|||||||
@@ -1,40 +1,24 @@
|
|||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
|
||||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||||
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
||||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||||
github.com/PuerkitoBio/purell v1.2.2 h1:irlAloIzZaJ5RP/+UcaT1Nw0H9on2HKWdRehCsbJWJw=
|
|
||||||
github.com/PuerkitoBio/purell v1.2.2/go.mod h1:ZwHcC/82TOaovDi//J/804umJFFmbOHPngi8iYYv/Eo=
|
|
||||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
|
|
||||||
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
|
||||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
|
||||||
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
|
||||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||||
github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
|
|
||||||
github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
|
|
||||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
|
||||||
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
|
||||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
|
|
||||||
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
|
|
||||||
github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I=
|
github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I=
|
||||||
github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||||
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
|
|
||||||
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
|
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
@@ -45,20 +29,14 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
|||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
|
||||||
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
||||||
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
|
||||||
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
|
||||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
|
||||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
|
||||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||||
@@ -66,35 +44,13 @@ github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9
|
|||||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||||
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
||||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||||
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
|
|
||||||
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
|
|
||||||
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
|
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
|
||||||
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
|
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
|
||||||
github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY=
|
|
||||||
github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
|
|
||||||
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
|
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
|
||||||
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
|
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
|
||||||
github.com/go-openapi/spec v0.22.6 h1:Tyy1pLaNCM8GBCFLoGYLonjJi6zykqyLCjXLc19ZPic=
|
|
||||||
github.com/go-openapi/spec v0.22.6/go.mod h1:HZvTHat+iH0PALQRWhrqIHtU/PEqxqd89fu0MxGlMeM=
|
|
||||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||||
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||||
github.com/go-openapi/swag v0.27.0 h1:8ecSuZlh4NXc3GsmAOqECIYqDTApCWaMe3gO4gjJNEE=
|
|
||||||
github.com/go-openapi/swag v0.27.0/go.mod h1:Kkgz9Ht0+ul9/aVdFmc9xSyPzUwf/aFF5KiFPBXfSY0=
|
|
||||||
github.com/go-openapi/swag/conv v0.27.0 h1:EKOH4feXrvdo8DbSsXSAqRT8fz1epEnS5O2IfXUOzE8=
|
|
||||||
github.com/go-openapi/swag/conv v0.27.0/go.mod h1:pfiv0uKQTbaGApk8Zs/lZV3uSjmSpa2FO1y183YngN8=
|
|
||||||
github.com/go-openapi/swag/jsonname v0.27.0 h1:4QVB//CKOdE8IOiBg19JNY2wfDS48MhesIquYBy2rUE=
|
|
||||||
github.com/go-openapi/swag/jsonname v0.27.0/go.mod h1:I1YsyvvhBuZsFXSW6I7ODfdyq13p7hDil//1T9/pFFk=
|
|
||||||
github.com/go-openapi/swag/jsonutils v0.27.0 h1:VYtd9jEQYeU4j8q5vdn5KWotF4vKywhGdMBrALtAsfE=
|
|
||||||
github.com/go-openapi/swag/jsonutils v0.27.0/go.mod h1:U7pb8AGuwhok3RDicHeHwSG4L3PXSq6PAL98Aon632g=
|
|
||||||
github.com/go-openapi/swag/loading v0.27.0 h1:s8DA9aPEdFH6OluHUYUn3DnIuoTdyWs9RwffXBUfyeI=
|
|
||||||
github.com/go-openapi/swag/loading v0.27.0/go.mod h1:VOz+Jg6UGGywcmRvYsI4fvtp+bd7NfioseGEPleYdA4=
|
|
||||||
github.com/go-openapi/swag/stringutils v0.27.0 h1:Of7w/HljWsNZvuxsUAnw3n+hCOyI6HLJOxW2kQRAxio=
|
|
||||||
github.com/go-openapi/swag/stringutils v0.27.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
|
|
||||||
github.com/go-openapi/swag/typeutils v0.27.0 h1:aCf4MSGo8NLwZP8Q6t32DWLJSvl/WwNqgmEG+xJ6v2o=
|
|
||||||
github.com/go-openapi/swag/typeutils v0.27.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
|
|
||||||
github.com/go-openapi/swag/yamlutils v0.27.0 h1:bQ6eAMil5X9tdcf7dMn4t15alzG6jddnrKPuKa/zxKM=
|
|
||||||
github.com/go-openapi/swag/yamlutils v0.27.0/go.mod h1:yRfIo7qqVkmJRQjX8exjA3AfcI8rH1KDNPsTparoCv4=
|
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
@@ -103,22 +59,14 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
|||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
|
|
||||||
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
|
|
||||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
|
|
||||||
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
|
||||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
|
||||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/gofrs/flock v0.10.0 h1:SHMXenfaB03KbroETaCMtbBg3Yn29v4w1r+tgy4ff4k=
|
github.com/gofrs/flock v0.10.0 h1:SHMXenfaB03KbroETaCMtbBg3Yn29v4w1r+tgy4ff4k=
|
||||||
github.com/gofrs/flock v0.10.0/go.mod h1:FirDy1Ing0mI2+kB6wk+vyyAH+e6xiE+EYA0jnzV9jc=
|
github.com/gofrs/flock v0.10.0/go.mod h1:FirDy1Ing0mI2+kB6wk+vyyAH+e6xiE+EYA0jnzV9jc=
|
||||||
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
|
||||||
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
@@ -126,11 +74,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
|
|||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
|
||||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
@@ -139,8 +84,6 @@ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7Ulw
|
|||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
|
||||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
@@ -153,8 +96,6 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
|
|||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
@@ -168,12 +109,8 @@ github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN
|
|||||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||||
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
||||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
|
|
||||||
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
|
||||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -181,15 +118,11 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||||
github.com/oracle/oci-go-sdk/v65 v65.120.0 h1:qpGdts2Yleg6TdmtxXkL8MAnsASO+SOG+iG/Nd8wUGk=
|
github.com/oracle/oci-go-sdk/v65 v65.121.0 h1:1J+5ARgrodrx8kzFy/hxznaoUzz43jr0EestCzEaOHw=
|
||||||
github.com/oracle/oci-go-sdk/v65 v65.120.0/go.mod h1:Pzy+BpgkDesvGZXEHgslwhIYobHCPHg6wRta1mWnlqQ=
|
github.com/oracle/oci-go-sdk/v65 v65.121.0/go.mod h1:Pzy+BpgkDesvGZXEHgslwhIYobHCPHg6wRta1mWnlqQ=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||||
@@ -198,8 +131,6 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
|||||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||||
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
|
|
||||||
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
@@ -244,27 +175,17 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS
|
|||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
|
|
||||||
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
|
||||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
golang.org/x/arch v0.29.0 h1:8sSET5wB0+exBm0FGmOtdHMqjlRdV2DRD3/IV6OZgho=
|
|
||||||
golang.org/x/arch v0.29.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
|
||||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
|
||||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
||||||
@@ -272,16 +193,12 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
|||||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
|
||||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -292,14 +209,11 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
@@ -307,8 +221,6 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
|
||||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
@@ -316,13 +228,9 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
|
|||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
|
||||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
@@ -346,19 +254,11 @@ gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
|||||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||||
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
|
|
||||||
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
|
|
||||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
|
||||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
|
||||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
|
||||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||||
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
|
||||||
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
|
||||||
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
|
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
|
||||||
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
|
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
|
||||||
|
|||||||
+70
-5
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -422,9 +423,51 @@ func aiPathID(c *gin.Context) (uint, bool) {
|
|||||||
return uint(id), true
|
return uint(id), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// modelCatalog 返回启用渠道聚合去重后的模型目录(含能力),黑名单添加弹窗用。
|
||||||
|
//
|
||||||
|
// @Summary 聚合模型目录
|
||||||
|
// @Tags AI 管理
|
||||||
|
// @Success 200 {object} itemsResponse[service.AggregatedModel]
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/ai-model-catalog [get]
|
||||||
|
func (h *aiAdminHandler) modelCatalog(c *gin.Context) {
|
||||||
|
items, err := h.gw.AggregatedModels(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
// aiSettingsResponse 是 AI 网关全局设置(文档与响应共用)。
|
// aiSettingsResponse 是 AI 网关全局设置(文档与响应共用)。
|
||||||
type aiSettingsResponse struct {
|
type aiSettingsResponse struct {
|
||||||
|
// FilterDeprecated 开启后已宣布弃用(即使未退役)的模型从列表与路由中排除
|
||||||
FilterDeprecated bool `json:"filterDeprecated"`
|
FilterDeprecated bool `json:"filterDeprecated"`
|
||||||
|
// StreamGuardEnabled / StreamGuardKB 是 Responses 流式保险丝:
|
||||||
|
// instructions+tools 合计超阈值(KB)的流式请求改非流式上游并合成 SSE
|
||||||
|
StreamGuardEnabled bool `json:"streamGuardEnabled"`
|
||||||
|
StreamGuardKB int `json:"streamGuardKB"`
|
||||||
|
// GrokWebSearch / GrokXSearch 是 xai. 模型服务端搜索工具默认注入开关;
|
||||||
|
// 请求 tools 已包含同名工具时不覆盖
|
||||||
|
GrokWebSearch bool `json:"grokWebSearch"`
|
||||||
|
GrokXSearch bool `json:"grokXSearch"`
|
||||||
|
// UpstreamWaitSeconds 是 responses 直通的上游无响应预算(秒):非流式为单次
|
||||||
|
// 尝试总超时,流式为等待响应头预算;multi-agent/搜索类模型需远超 60s
|
||||||
|
UpstreamWaitSeconds int `json:"upstreamWaitSeconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentAiSettings 汇总网关运行时设置为响应体。
|
||||||
|
func (h *aiAdminHandler) currentAiSettings() aiSettingsResponse {
|
||||||
|
guardOn, guardKB := h.gw.StreamGuard()
|
||||||
|
web, x := h.gw.GrokSearch()
|
||||||
|
return aiSettingsResponse{
|
||||||
|
FilterDeprecated: h.gw.FilterDeprecated(),
|
||||||
|
StreamGuardEnabled: guardOn,
|
||||||
|
StreamGuardKB: guardKB,
|
||||||
|
GrokWebSearch: web,
|
||||||
|
GrokXSearch: x,
|
||||||
|
UpstreamWaitSeconds: int(h.gw.UpstreamWait() / time.Second),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// aiSettings 返回 AI 网关全局设置。
|
// aiSettings 返回 AI 网关全局设置。
|
||||||
@@ -435,15 +478,16 @@ type aiSettingsResponse struct {
|
|||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-settings [get]
|
// @Router /api/v1/ai-settings [get]
|
||||||
func (h *aiAdminHandler) aiSettings(c *gin.Context) {
|
func (h *aiAdminHandler) aiSettings(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, aiSettingsResponse{FilterDeprecated: h.gw.FilterDeprecated()})
|
c.JSON(http.StatusOK, h.currentAiSettings())
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateAiSettings 更新 AI 网关全局设置(当前仅「过滤弃用模型」开关)。
|
// updateAiSettings 更新 AI 网关全局设置(过滤弃用/流式保险丝/grok 搜索工具默认注入)。
|
||||||
//
|
//
|
||||||
// @Summary 更新 AI 网关全局设置
|
// @Summary 更新 AI 网关全局设置
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Param body body aiSettingsResponse true "开启后已宣布弃用(即使未退役)的模型从列表与路由中排除"
|
// @Param body body aiSettingsResponse true "全量提交;保险丝阈值限 1..1024 KB,上游无响应预算限 30..900 秒"
|
||||||
// @Success 200 {object} aiSettingsResponse
|
// @Success 200 {object} aiSettingsResponse
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-settings [put]
|
// @Router /api/v1/ai-settings [put]
|
||||||
func (h *aiAdminHandler) updateAiSettings(c *gin.Context) {
|
func (h *aiAdminHandler) updateAiSettings(c *gin.Context) {
|
||||||
@@ -452,9 +496,30 @@ func (h *aiAdminHandler) updateAiSettings(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.gw.SetFilterDeprecated(c.Request.Context(), req.FilterDeprecated); err != nil {
|
if req.StreamGuardKB < 1 || req.StreamGuardKB > 1024 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "streamGuardKB 须在 1..1024"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.UpstreamWaitSeconds < 30 || req.UpstreamWaitSeconds > 900 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "upstreamWaitSeconds 须在 30..900"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
if err := h.gw.SetFilterDeprecated(ctx, req.FilterDeprecated); err != nil {
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, aiSettingsResponse{FilterDeprecated: h.gw.FilterDeprecated()})
|
if err := h.gw.SetStreamGuard(ctx, req.StreamGuardEnabled, req.StreamGuardKB); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.gw.SetGrokSearch(ctx, req.GrokWebSearch, req.GrokXSearch); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.gw.SetUpstreamWait(ctx, req.UpstreamWaitSeconds); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, h.currentAiSettings())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -587,8 +587,13 @@ func (h *aiGatewayHandler) responsesPassthrough(c *gin.Context, raw []byte, req
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
logRespCompat(req.Model, compat)
|
logRespCompat(req.Model, compat)
|
||||||
|
web, x := h.gw.GrokSearch()
|
||||||
|
if injectedBody, injected := service.RespInjectGrokTools(body, req.Model, web, x); len(injected) > 0 {
|
||||||
|
body = injectedBody
|
||||||
|
log.Printf("responses 直通(model=%s): 默认注入 %s", req.Model, strings.Join(injected, ", "))
|
||||||
|
}
|
||||||
if req.Stream {
|
if req.Stream {
|
||||||
if len(body) > service.RespStreamUpgradeLimit {
|
if on, kb := h.gw.StreamGuard(); on && service.RespGuardBytes(body) > kb*1024 {
|
||||||
h.responsesStreamUpgrade(c, body, req, compat)
|
h.responsesStreamUpgrade(c, body, req, compat)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -630,9 +635,9 @@ func logRespCompat(model string, compat service.RespCompat) {
|
|||||||
log.Printf("responses 直通(model=%s): %s", model, strings.Join(parts, "; "))
|
log.Printf("responses 直通(model=%s): %s", model, strings.Join(parts, "; "))
|
||||||
}
|
}
|
||||||
|
|
||||||
// responsesStreamUpgrade 流式升级回退:上游对超大流式请求会在推理途中掐断
|
// responsesStreamUpgrade 流式升级回退:instructions+tools 合计超过保险丝阈值
|
||||||
// (实测 >~82KB,见 RespStreamUpgradeLimit),超限时改调非流式上游拿完整响应,
|
// (设置页 AI Tab 配置,默认开 60KB;上游对 >≈64.5KB 会静默断流)时改调非流式
|
||||||
// 本地合成最小 SSE 事件序列回给客户端;丢失增量输出,换会话不中断。
|
// 上游拿完整响应,本地合成最小 SSE 事件序列回给客户端;丢失增量输出,换会话不中断。
|
||||||
func (h *aiGatewayHandler) responsesStreamUpgrade(c *gin.Context, body []byte, req aiwire.RespRequest, compat service.RespCompat) {
|
func (h *aiGatewayHandler) responsesStreamUpgrade(c *gin.Context, body []byte, req aiwire.RespRequest, compat service.RespCompat) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
nsBody, err := service.RespDisableStream(body)
|
nsBody, err := service.RespDisableStream(body)
|
||||||
@@ -640,7 +645,8 @@ func (h *aiGatewayHandler) responsesStreamUpgrade(c *gin.Context, body []byte, r
|
|||||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("responses 直通(model=%s): 请求体 %dKB 超流式安全上限,改走非流式合成 SSE", req.Model, len(body)/1024)
|
log.Printf("responses 直通(model=%s): instructions+tools %dKB 超保险丝阈值,改走非流式合成 SSE",
|
||||||
|
req.Model, service.RespGuardBytes(body)/1024)
|
||||||
payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), nsBody, req.Model, keyGroup(c))
|
payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), nsBody, req.Model, keyGroup(c))
|
||||||
entry := h.logEntry(c, "responses", req.Model, true, meta, start)
|
entry := h.logEntry(c, "responses", req.Model, true, meta, start)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ func registerAiAdmin(secured *gin.RouterGroup, aiGateway *service.AiGatewayServi
|
|||||||
secured.GET("/ai-channels/:id/models", aiadmin.listChannelModels)
|
secured.GET("/ai-channels/:id/models", aiadmin.listChannelModels)
|
||||||
secured.POST("/ai-channels/:id/test-model", aiadmin.testChannelModel)
|
secured.POST("/ai-channels/:id/test-model", aiadmin.testChannelModel)
|
||||||
secured.GET("/ai-models", aiadmin.gatewayModels)
|
secured.GET("/ai-models", aiadmin.gatewayModels)
|
||||||
|
secured.GET("/ai-model-catalog", aiadmin.modelCatalog)
|
||||||
secured.GET("/ai-settings", aiadmin.aiSettings)
|
secured.GET("/ai-settings", aiadmin.aiSettings)
|
||||||
secured.PUT("/ai-settings", aiadmin.updateAiSettings)
|
secured.PUT("/ai-settings", aiadmin.updateAiSettings)
|
||||||
secured.GET("/ai-blacklist", aiadmin.listBlacklist)
|
secured.GET("/ai-blacklist", aiadmin.listBlacklist)
|
||||||
|
|||||||
@@ -68,13 +68,15 @@ func (h *ociConfigHandler) costs(c *gin.Context) {
|
|||||||
// ---- 租户审计日志 ----
|
// ---- 租户审计日志 ----
|
||||||
|
|
||||||
// getAuditEvents 批式懒加载查询审计事件:cursor 为空自当前时刻首查,
|
// getAuditEvents 批式懒加载查询审计事件:cursor 为空自当前时刻首查,
|
||||||
// 非空从上次响应游标继续向更早回溯;limit 单批目标条数(缺省 100,上限 200)。
|
// 非空从上次响应游标继续向更早回溯;limit 单批目标条数(缺省 100,上限 200);
|
||||||
|
// q 为服务端全文检索关键字,仅首查生效,续查沿用游标内嵌关键字。
|
||||||
//
|
//
|
||||||
// @Summary 批式懒加载查询租户 OCI 审计事件
|
// @Summary 批式懒加载查询租户 OCI 审计事件
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @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"
|
||||||
|
// @Param q query string false "检索关键字(服务端全文匹配,支持 * 通配;仅首查生效)"
|
||||||
// @Success 200 {object} service.AuditEventsView
|
// @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]
|
||||||
@@ -84,7 +86,7 @@ func (h *ociConfigHandler) getAuditEvents(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
limit, _ := strconv.Atoi(c.Query("limit"))
|
limit, _ := strconv.Atoi(c.Query("limit"))
|
||||||
q := service.AuditQuery{Region: c.Query("region"), Cursor: c.Query("cursor"), Limit: limit}
|
q := service.AuditQuery{Region: c.Query("region"), Cursor: c.Query("cursor"), Limit: limit, Q: c.Query("q")}
|
||||||
result, err := h.svc.AuditEvents(c.Request.Context(), id, q)
|
result, err := h.svc.AuditEvents(c.Request.Context(), id, q)
|
||||||
if errors.Is(err, service.ErrInvalidAuditCursor) {
|
if errors.Is(err, service.ErrInvalidAuditCursor) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
|||||||
+471
-186
@@ -6,19 +6,29 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/oracle/oci-go-sdk/v65/audit"
|
"github.com/oracle/oci-go-sdk/v65/audit"
|
||||||
"github.com/oracle/oci-go-sdk/v65/common"
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/loggingsearch"
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxAuditPages 限制单次查询的翻页数:繁忙租户单日事件可上千,
|
// maxAuditPages 限制单次查询的翻页数:每页最多 auditSearchPageLimit 条,
|
||||||
// 到限即返回 Truncated=true,由调用方收窄时间窗。
|
// 到限即截断(窗口式回传 Truncated,批式留游标),由调用方续查。
|
||||||
// 默认过滤(噪声事件/内网发起)后有效结果变少,页数放宽到 10 缓解截断。
|
|
||||||
const maxAuditPages = 10
|
const maxAuditPages = 10
|
||||||
|
|
||||||
|
// auditBatchTimeBudget 是批式查询的单批耗时预算:全文检索命中稀疏时
|
||||||
|
// 大窗扫描单页可达十余秒,超时即带游标返回,把长回溯拆成多个有界请求,
|
||||||
|
// 前端按已回溯位置展示进度并自动续查。
|
||||||
|
const auditBatchTimeBudget = 20 * time.Second
|
||||||
|
|
||||||
|
// auditSearchPageLimit 是 SearchLogs 单页条数(API 上限 1000):批式查询
|
||||||
|
// 攒满目标条数(~100)即携整页返回,页取 200 兼顾单页凑满一批与响应体量。
|
||||||
|
const auditSearchPageLimit = 200
|
||||||
|
|
||||||
// AuditEvent 是审计事件的列表精简视图;EventId 为 CloudEvents 全局唯一 id,
|
// AuditEvent 是审计事件的列表精简视图;EventId 为 CloudEvents 全局唯一 id,
|
||||||
// 详情反查的键。Raw 为 SDK 原始事件的 JSON 序列化,由 service 层剥离进缓存,
|
// 详情反查的键。Raw 为 _Audit 日志 logContent 原文,由 service 层剥离进缓存,
|
||||||
// 列表响应不再携带(详情接口按 eventId 取回)。
|
// 列表响应不再携带(详情接口按 eventId 取回)。
|
||||||
type AuditEvent struct {
|
type AuditEvent struct {
|
||||||
EventId string `json:"eventId"`
|
EventId string `json:"eventId"`
|
||||||
@@ -35,7 +45,7 @@ type AuditEvent struct {
|
|||||||
Raw json.RawMessage `json:"raw,omitempty"`
|
Raw json.RawMessage `json:"raw,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuditEventsResult 是一次审计查询的结果;Truncated 表示翻页到限被截断,
|
// AuditEventsResult 是一次窗口式审计查询的结果;Truncated 表示翻页到限被截断,
|
||||||
// 此时 NextPage 携带 opc-next-page 游标,同一时间窗回传可断点续翻。
|
// 此时 NextPage 携带 opc-next-page 游标,同一时间窗回传可断点续翻。
|
||||||
type AuditEventsResult struct {
|
type AuditEventsResult struct {
|
||||||
Items []AuditEvent `json:"items"`
|
Items []AuditEvent `json:"items"`
|
||||||
@@ -43,6 +53,324 @@ type AuditEventsResult struct {
|
|||||||
NextPage string `json:"nextPage,omitempty"`
|
NextPage string `json:"nextPage,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// auditSearchClient 构造区域化的日志搜索客户端。审计数据源为 Logging Search
|
||||||
|
// 的 _Audit 日志:Audit API 无排序参数、窗口内固定按处理时间正序,首批只能
|
||||||
|
// 拿到窗口内最旧的一段;Logging Search 支持 datetime 倒序,才能从最新回溯。
|
||||||
|
func (c *RealClient) auditSearchClient(cred Credentials, region string) (loggingsearch.LogSearchClient, error) {
|
||||||
|
sc, err := loggingsearch.NewLogSearchClientWithConfigurationProvider(provider(cred))
|
||||||
|
if err != nil {
|
||||||
|
return sc, fmt.Errorf("new logging search client: %w", err)
|
||||||
|
}
|
||||||
|
applyProxy(&sc.BaseClient, cred)
|
||||||
|
if region != "" {
|
||||||
|
sc.SetRegion(normalizeRegion(region))
|
||||||
|
}
|
||||||
|
return sc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditSearchQuery 组装租户根 compartment 审计日志的倒序检索语句;
|
||||||
|
// SummarizeMetricsData 遥测噪声占比高,服务端先滤一道减少无效翻页。
|
||||||
|
// q 非空时追加 logContent 全文包含匹配——它扫的是整条 JSON 的所有值,只当
|
||||||
|
// 粗筛;可见字段的精筛由 filterAuditTerm 兜底,避免隐藏元数据误命中。
|
||||||
|
func auditSearchQuery(tenancyOCID, q string) string {
|
||||||
|
query := fmt.Sprintf("search %q | where data.eventName != 'SummarizeMetricsData'", tenancyOCID+"/_Audit")
|
||||||
|
if term := SanitizeAuditTerm(q); term != "" {
|
||||||
|
query += fmt.Sprintf(" and logContent = '*%s*'", term)
|
||||||
|
}
|
||||||
|
return query + " | sort by datetime desc"
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditTermMaxLen 限制检索关键字长度,防止游标与查询语句被撑爆。
|
||||||
|
const auditTermMaxLen = 100
|
||||||
|
|
||||||
|
// SanitizeAuditTerm 归一检索关键字:去除引号/反斜杠/控制字符防语句注入
|
||||||
|
// (查询目标已锁定本租户 _Audit 流,注入最坏只是语法错),截断超长输入;
|
||||||
|
// 保留 * 供用户通配。返回空串表示不追加过滤子句。
|
||||||
|
// service 构造首查游标与本包组装语句共用,对篡改游标二次消毒兜底。
|
||||||
|
func SanitizeAuditTerm(q string) string {
|
||||||
|
out := make([]rune, 0, len(q))
|
||||||
|
for _, r := range q {
|
||||||
|
if r == '\'' || r == '"' || r == '\\' || r < 0x20 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
if len(out) >= auditTermMaxLen {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAuditEvents 实现 Client:实时查询租户根 compartment 在 [start, end) 内的
|
||||||
|
// 审计事件,最多翻 maxAuditPages 页,结果按发生时间倒序;纯读不落库。
|
||||||
|
// page 非空时从该游标断点续翻(必须配同一时间窗);到限截断时回传 NextPage。
|
||||||
|
// Search 配额为零的租户自动回退 Audit API 重查同一窗口(page 跨通道失效,重头吃窗)。
|
||||||
|
func (c *RealClient) ListAuditEvents(ctx context.Context, cred Credentials, region string, start, end time.Time, page string) (AuditEventsResult, error) {
|
||||||
|
f := c.newAuditFetchers(cred, region)
|
||||||
|
res, err := listAuditWindow(ctx, f.search, AuditCursor{Start: start, End: end, Page: page})
|
||||||
|
if err != nil && isSearchQuotaZero(err) {
|
||||||
|
res, err = listAuditWindow(ctx, f.audit, AuditCursor{Start: start, End: end})
|
||||||
|
}
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// listAuditWindow 用给定取页函数吃一个固定时间窗,最多 maxAuditPages 页;
|
||||||
|
// 页预算耗尽即截断,NextPage 携带未消费的窗内游标。
|
||||||
|
func listAuditWindow(ctx context.Context, fetch auditPageFetch, cur AuditCursor) (AuditEventsResult, error) {
|
||||||
|
result := AuditEventsResult{Items: []AuditEvent{}}
|
||||||
|
for i := 0; i < maxAuditPages; i++ {
|
||||||
|
items, next, err := fetch(ctx, cur)
|
||||||
|
if err != nil {
|
||||||
|
return AuditEventsResult{}, err
|
||||||
|
}
|
||||||
|
result.Items = appendKeptAuditEvents(result.Items, items)
|
||||||
|
if next == "" {
|
||||||
|
sortAuditEvents(result.Items)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
cur.Page = next
|
||||||
|
}
|
||||||
|
result.Truncated = true
|
||||||
|
result.NextPage = cur.Page
|
||||||
|
sortAuditEvents(result.Items)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditPageFetch 拉取游标位置的一页已映射事件,返回窗内下一页游标。
|
||||||
|
type auditPageFetch func(ctx context.Context, cur AuditCursor) ([]AuditEvent, string, error)
|
||||||
|
|
||||||
|
// auditFetchers 汇集两条数据通道:search 为 Logging Search 倒序主路,
|
||||||
|
// audit 为 Search 配额为零租户的 Audit API 回退路。
|
||||||
|
type auditFetchers struct {
|
||||||
|
search auditPageFetch
|
||||||
|
audit auditPageFetch
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAuditFetchers 构造两条通道的取页闭包;客户端惰性初始化,
|
||||||
|
// 各模式的续查不会白建用不到的客户端。
|
||||||
|
func (c *RealClient) newAuditFetchers(cred Credentials, region string) auditFetchers {
|
||||||
|
return auditFetchers{search: c.searchFetcher(cred, region), audit: c.auditAPIFetcher(cred, region)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchFetcher 构造 Logging Search 通道的取页闭包。
|
||||||
|
func (c *RealClient) searchFetcher(cred Credentials, region string) auditPageFetch {
|
||||||
|
var sc *loggingsearch.LogSearchClient
|
||||||
|
return func(ctx context.Context, cur AuditCursor) ([]AuditEvent, string, error) {
|
||||||
|
if sc == nil {
|
||||||
|
cli, err := c.auditSearchClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
sc = &cli
|
||||||
|
}
|
||||||
|
return searchAuditPage(ctx, *sc, cred.TenancyOCID, cur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditAPIFetcher 构造 Audit API 回退通道的取页闭包。
|
||||||
|
func (c *RealClient) auditAPIFetcher(cred Credentials, region string) auditPageFetch {
|
||||||
|
var ac *audit.AuditClient
|
||||||
|
return func(ctx context.Context, cur AuditCursor) ([]AuditEvent, string, error) {
|
||||||
|
if ac == nil {
|
||||||
|
cli, err := c.auditClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
ac = &cli
|
||||||
|
}
|
||||||
|
return listAuditPage(ctx, *ac, cred.TenancyOCID, cur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSearchQuotaZero 识别「租户 Logging Search 配额为零」的失败:此类租户该
|
||||||
|
// 服务永久不可用(maxQueriesPerMinute/maxConcurrentQueries 均为 0),应回退
|
||||||
|
// Audit API;普通限流(配额非零)不回退,避免数据通道来回切换。
|
||||||
|
func isSearchQuotaZero(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := strings.ToLower(strings.ReplaceAll(err.Error(), " ", ""))
|
||||||
|
return strings.Contains(msg, "ratelimitexceeded") && strings.Contains(msg, "maxqueriesperminute:0,")
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendKeptAuditEvents 过滤噪声后追加一页已映射事件;窗口式与批式查询共用。
|
||||||
|
func appendKeptAuditEvents(dst []AuditEvent, items []AuditEvent) []AuditEvent {
|
||||||
|
for _, ev := range items {
|
||||||
|
if keepAuditEvent(ev) {
|
||||||
|
dst = append(dst, ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 批式懒加载查询:分窗回溯 + 游标续查 ----
|
||||||
|
|
||||||
|
// 批式查询参数:单批翻页预算沿用 maxAuditPages;首窗 24h,连续空窗倍增
|
||||||
|
// 加速跨越闲置期,上限 14 天(Logging Search 单次查询时间窗硬限);
|
||||||
|
// 回溯下限为审计事件保留期 365 天。
|
||||||
|
const (
|
||||||
|
auditWindowHours = 24
|
||||||
|
auditWindowMaxHours = 336
|
||||||
|
auditRetentionDays = 365
|
||||||
|
)
|
||||||
|
|
||||||
|
// auditModeFallback 标记游标处于 Audit API 回退模式:部分租户的
|
||||||
|
// Logging Search 服务配额为零(maxQueriesPerMinute: 0),永久不可用。
|
||||||
|
const auditModeFallback = "a"
|
||||||
|
|
||||||
|
// auditFallbackWindowHours 是回退模式的基准窗宽:Audit API 窗口内固定按
|
||||||
|
// 处理时间正序且无排序参数,只能小窗回溯 + 前端全局重排保住从新到旧的体验。
|
||||||
|
const auditFallbackWindowHours = 1
|
||||||
|
|
||||||
|
// AuditCursor 是批式查询的续查位置:当前时间窗、窗内翻页游标、当前窗宽
|
||||||
|
// (小时,空窗倍增的记忆)、检索关键字(随游标续查,保证跨批过滤一致)
|
||||||
|
// 与数据通道模式(空为 Search 主路,"a" 为 Audit API 回退,续查沿用不再试错)。
|
||||||
|
// 序列化为不透明 cursor 由 service 层负责。
|
||||||
|
type AuditCursor struct {
|
||||||
|
Start time.Time `json:"s"`
|
||||||
|
End time.Time `json:"e"`
|
||||||
|
Page string `json:"p,omitempty"`
|
||||||
|
WindowHours int `json:"w"`
|
||||||
|
Q string `json:"q,omitempty"`
|
||||||
|
M string `json:"m,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// toFallback 把游标切到 Audit API 回退模式:Search 页游标跨通道失效须清空;
|
||||||
|
// 首窗收窄到基准窗宽,避免大窗正序分页又回到「首批全是窗口内最旧事件」的老问题。
|
||||||
|
func (cur AuditCursor) toFallback() AuditCursor {
|
||||||
|
cur.M = auditModeFallback
|
||||||
|
cur.Page = ""
|
||||||
|
cur.WindowHours = auditFallbackWindowHours
|
||||||
|
if cur.End.Sub(cur.Start) > auditFallbackWindowHours*time.Hour {
|
||||||
|
cur.Start = cur.End.Add(-auditFallbackWindowHours * time.Hour)
|
||||||
|
}
|
||||||
|
return cur
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuditCursor 构造首查游标:自 now 起回溯第一个 24h 窗。
|
||||||
|
func NewAuditCursor(now time.Time) AuditCursor {
|
||||||
|
end := now.UTC().Truncate(time.Minute)
|
||||||
|
return AuditCursor{Start: end.Add(-auditWindowHours * time.Hour), End: end, WindowHours: auditWindowHours}
|
||||||
|
}
|
||||||
|
|
||||||
|
// advance 推进到紧邻更早的窗;empty 表示刚结束的窗无保留事件,窗宽倍增,
|
||||||
|
// 否则重置为该模式基准窗宽。done 为 true 表示已越过保留期尽头。
|
||||||
|
func (cur AuditCursor) advance(now time.Time, empty bool) (AuditCursor, bool) {
|
||||||
|
base := auditWindowHours
|
||||||
|
if cur.M == auditModeFallback {
|
||||||
|
base = auditFallbackWindowHours
|
||||||
|
}
|
||||||
|
w := cur.WindowHours
|
||||||
|
if w <= 0 {
|
||||||
|
w = base
|
||||||
|
}
|
||||||
|
if empty {
|
||||||
|
if w *= 2; w > auditWindowMaxHours {
|
||||||
|
w = auditWindowMaxHours
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
w = base
|
||||||
|
}
|
||||||
|
end := cur.Start
|
||||||
|
if end.Before(now.UTC().AddDate(0, 0, -auditRetentionDays)) {
|
||||||
|
return cur, true
|
||||||
|
}
|
||||||
|
return AuditCursor{Start: end.Add(-time.Duration(w) * time.Hour), End: end, WindowHours: w, Q: cur.Q, M: cur.M}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditBatchResult 是一批懒加载结果;Cursor 为 nil 且 Exhausted 为 true
|
||||||
|
// 表示已回溯到保留期尽头,无更早数据。
|
||||||
|
type AuditBatchResult struct {
|
||||||
|
Items []AuditEvent
|
||||||
|
Cursor *AuditCursor
|
||||||
|
Exhausted bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAuditEventsBatch 实现 Client:从 cur 位置向更早方向收集约 limit 条
|
||||||
|
// 保留事件;单批受页预算与时间预算双重约束,不足额也返回,由前端按需续查。
|
||||||
|
// 倒序返回下,窗口不重叠 + 窗内游标续翻保证跨批不重不漏。
|
||||||
|
func (c *RealClient) ListAuditEventsBatch(ctx context.Context, cred Credentials, region string, cur AuditCursor, limit int) (AuditBatchResult, error) {
|
||||||
|
return listAuditBatch(ctx, c.newAuditFetchers(cred, region), cur, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listAuditBatch 是批式回溯的通道无关内核,取页函数注入便于测试。
|
||||||
|
func listAuditBatch(ctx context.Context, f auditFetchers, cur AuditCursor, limit int) (AuditBatchResult, error) {
|
||||||
|
res := AuditBatchResult{Items: []AuditEvent{}}
|
||||||
|
windowHasKept := false
|
||||||
|
deadline := time.Now().Add(auditBatchTimeBudget)
|
||||||
|
for budget := maxAuditPages; budget > 0 && len(res.Items) < limit && time.Now().Before(deadline); budget-- {
|
||||||
|
items, next, nextCur, err := fetchAuditPage(ctx, f, cur)
|
||||||
|
if err != nil {
|
||||||
|
return AuditBatchResult{}, err
|
||||||
|
}
|
||||||
|
cur = nextCur
|
||||||
|
before := len(res.Items)
|
||||||
|
res.Items = appendKeptAuditEvents(res.Items, filterAuditTerm(items, cur))
|
||||||
|
windowHasKept = windowHasKept || len(res.Items) > before
|
||||||
|
if next != "" {
|
||||||
|
cur.Page = next
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
adv, done := cur.advance(time.Now(), !windowHasKept)
|
||||||
|
if done {
|
||||||
|
res.Exhausted = true
|
||||||
|
sortAuditEvents(res.Items)
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
cur, windowHasKept = adv, false
|
||||||
|
}
|
||||||
|
sortAuditEvents(res.Items)
|
||||||
|
res.Cursor = &cur
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchAuditPage 按游标模式取一页;Search 主路报「配额为零」时切到回退游标
|
||||||
|
// 并立即用 Audit API 重试,后续批次凭游标模式直达回退通道不再试错。
|
||||||
|
func fetchAuditPage(ctx context.Context, f auditFetchers, cur AuditCursor) ([]AuditEvent, string, AuditCursor, error) {
|
||||||
|
if cur.M == auditModeFallback {
|
||||||
|
items, next, err := f.audit(ctx, cur)
|
||||||
|
return items, next, cur, err
|
||||||
|
}
|
||||||
|
items, next, err := f.search(ctx, cur)
|
||||||
|
if err != nil && isSearchQuotaZero(err) {
|
||||||
|
cur = cur.toFallback()
|
||||||
|
items, next, err = f.audit(ctx, cur)
|
||||||
|
}
|
||||||
|
return items, next, cur, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterAuditTerm 关键字精筛:只认列表可见字段(matchesAuditTerm),两条通道
|
||||||
|
// 语义一致。Search 主路的 logContent 全文条件会命中隐藏认证元数据(如
|
||||||
|
// opc-principal 头里的 ttype:login),只作粗筛减少翻页,不作为最终判定。
|
||||||
|
func filterAuditTerm(items []AuditEvent, cur AuditCursor) []AuditEvent {
|
||||||
|
if cur.Q == "" {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
out := items[:0]
|
||||||
|
for _, ev := range items {
|
||||||
|
if matchesAuditTerm(ev, cur.Q) {
|
||||||
|
out = append(out, ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesAuditTerm 判断事件是否命中关键字:不区分大小写的包含匹配,
|
||||||
|
// * 作为通配分段、各段都出现即命中,近似 Search 通道的 logContent 语义。
|
||||||
|
func matchesAuditTerm(ev AuditEvent, q string) bool {
|
||||||
|
hay := strings.ToLower(strings.Join([]string{
|
||||||
|
ev.EventName, ev.Source, ev.ResourceName, ev.CompartmentName,
|
||||||
|
ev.PrincipalName, ev.IPAddress, ev.Status, ev.RequestAction, ev.RequestPath,
|
||||||
|
}, "\n"))
|
||||||
|
for _, part := range strings.Split(strings.ToLower(q), "*") {
|
||||||
|
if part != "" && !strings.Contains(hay, part) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditClient 构造区域化的 Audit API 客户端(回退通道)。
|
||||||
func (c *RealClient) auditClient(cred Credentials, region string) (audit.AuditClient, error) {
|
func (c *RealClient) auditClient(cred Credentials, region string) (audit.AuditClient, error) {
|
||||||
ac, err := audit.NewAuditClientWithConfigurationProvider(provider(cred))
|
ac, err := audit.NewAuditClientWithConfigurationProvider(provider(cred))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -55,153 +383,10 @@ func (c *RealClient) auditClient(cred Credentials, region string) (audit.AuditCl
|
|||||||
return ac, nil
|
return ac, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAuditEvents 实现 Client:实时查询租户根 compartment 在 [start, end) 内的
|
// listAuditPage 拉取窗口内一页 Audit API 原始事件并压平;该 API 窗口内固定
|
||||||
// 审计事件,最多翻 maxAuditPages 页,结果按发生时间倒序;纯读不落库。
|
// 正序且只接受分钟粒度(起止秒与毫秒必须为 0)。Raw 为 SDK 事件原文,
|
||||||
// page 非空时从该游标断点续翻(必须配同一时间窗);到限截断时回传 NextPage。
|
// 与 Search 通道的 logContent 形态不同,详情弹窗均按任意 JSON 渲染。
|
||||||
func (c *RealClient) ListAuditEvents(ctx context.Context, cred Credentials, region string, start, end time.Time, page string) (AuditEventsResult, error) {
|
func listAuditPage(ctx context.Context, ac audit.AuditClient, tenancyOCID string, cur AuditCursor) ([]AuditEvent, string, error) {
|
||||||
ac, err := c.auditClient(cred, region)
|
|
||||||
if err != nil {
|
|
||||||
return AuditEventsResult{}, err
|
|
||||||
}
|
|
||||||
// Audit API 只接受分钟粒度:起止时间的秒与毫秒必须为 0
|
|
||||||
req := audit.ListEventsRequest{
|
|
||||||
CompartmentId: &cred.TenancyOCID,
|
|
||||||
StartTime: &common.SDKTime{Time: start.UTC().Truncate(time.Minute)},
|
|
||||||
EndTime: &common.SDKTime{Time: end.UTC().Truncate(time.Minute)},
|
|
||||||
}
|
|
||||||
if page != "" {
|
|
||||||
req.Page = &page
|
|
||||||
}
|
|
||||||
result := AuditEventsResult{Items: []AuditEvent{}}
|
|
||||||
for i := 0; i < maxAuditPages; i++ {
|
|
||||||
resp, err := ac.ListEvents(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
return AuditEventsResult{}, fmt.Errorf("list audit events: %w", err)
|
|
||||||
}
|
|
||||||
appendAuditEvents(&result, resp.Items)
|
|
||||||
if resp.OpcNextPage == nil {
|
|
||||||
sortAuditEvents(result.Items)
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
req.Page = resp.OpcNextPage
|
|
||||||
}
|
|
||||||
result.Truncated = true
|
|
||||||
result.NextPage = deref(req.Page)
|
|
||||||
sortAuditEvents(result.Items)
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// appendAuditEvents 过滤噪声后追加一页事件;原始事件只对保留条目序列化。
|
|
||||||
func appendAuditEvents(result *AuditEventsResult, items []audit.AuditEvent) {
|
|
||||||
result.Items = appendKeptAuditEvents(result.Items, items)
|
|
||||||
}
|
|
||||||
|
|
||||||
// appendKeptAuditEvents 是过滤追加的通用形态,窗口式与批式查询共用。
|
|
||||||
func appendKeptAuditEvents(dst []AuditEvent, items []audit.AuditEvent) []AuditEvent {
|
|
||||||
for _, ev := range items {
|
|
||||||
out := toAuditEvent(ev)
|
|
||||||
if !keepAuditEvent(out) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if raw, mErr := json.Marshal(ev); mErr == nil {
|
|
||||||
out.Raw = raw
|
|
||||||
}
|
|
||||||
dst = append(dst, out)
|
|
||||||
}
|
|
||||||
return dst
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 批式懒加载查询:分窗回溯 + 游标续查 ----
|
|
||||||
|
|
||||||
// 批式查询参数:单批 OCI 翻页预算沿用 maxAuditPages;首窗 24h,
|
|
||||||
// 连续空窗倍增(上限 30 天)加速跨越闲置期;回溯下限为事件保留期 365 天。
|
|
||||||
const (
|
|
||||||
auditWindowHours = 24
|
|
||||||
auditWindowMaxHours = 720
|
|
||||||
auditRetentionDays = 365
|
|
||||||
)
|
|
||||||
|
|
||||||
// AuditCursor 是批式查询的续查位置:当前时间窗、窗内 OCI 翻页游标
|
|
||||||
// 与当前窗宽(小时,空窗倍增的记忆)。序列化为不透明 cursor 由 service 层负责。
|
|
||||||
type AuditCursor struct {
|
|
||||||
Start time.Time `json:"s"`
|
|
||||||
End time.Time `json:"e"`
|
|
||||||
Page string `json:"p,omitempty"`
|
|
||||||
WindowHours int `json:"w"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAuditCursor 构造首查游标:自 now 起回溯第一个 24h 窗。
|
|
||||||
func NewAuditCursor(now time.Time) AuditCursor {
|
|
||||||
end := now.UTC().Truncate(time.Minute)
|
|
||||||
return AuditCursor{Start: end.Add(-auditWindowHours * time.Hour), End: end, WindowHours: auditWindowHours}
|
|
||||||
}
|
|
||||||
|
|
||||||
// advance 推进到紧邻更早的窗;empty 表示刚结束的窗无保留事件,窗宽倍增,
|
|
||||||
// 否则重置 24h。done 为 true 表示已越过保留期尽头。
|
|
||||||
func (cur AuditCursor) advance(now time.Time, empty bool) (AuditCursor, bool) {
|
|
||||||
w := cur.WindowHours
|
|
||||||
if w <= 0 {
|
|
||||||
w = auditWindowHours
|
|
||||||
}
|
|
||||||
if empty {
|
|
||||||
if w *= 2; w > auditWindowMaxHours {
|
|
||||||
w = auditWindowMaxHours
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
w = auditWindowHours
|
|
||||||
}
|
|
||||||
end := cur.Start
|
|
||||||
if end.Before(now.UTC().AddDate(0, 0, -auditRetentionDays)) {
|
|
||||||
return cur, true
|
|
||||||
}
|
|
||||||
return AuditCursor{Start: end.Add(-time.Duration(w) * time.Hour), End: end, WindowHours: w}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuditBatchResult 是一批懒加载结果;Cursor 为 nil 且 Exhausted 为 true
|
|
||||||
// 表示已回溯到保留期尽头,无更早数据。
|
|
||||||
type AuditBatchResult struct {
|
|
||||||
Items []AuditEvent
|
|
||||||
Cursor *AuditCursor
|
|
||||||
Exhausted bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListAuditEventsBatch 实现 Client:从 cur 位置向更早方向收集约 limit 条
|
|
||||||
// 保留事件;单批最多消费 maxAuditPages 页 OCI 调用,不足额也按预算返回,
|
|
||||||
// 由前端按需续查。窗口不重叠 + 窗内游标续翻保证跨批不重不漏。
|
|
||||||
func (c *RealClient) ListAuditEventsBatch(ctx context.Context, cred Credentials, region string, cur AuditCursor, limit int) (AuditBatchResult, error) {
|
|
||||||
ac, err := c.auditClient(cred, region)
|
|
||||||
if err != nil {
|
|
||||||
return AuditBatchResult{}, err
|
|
||||||
}
|
|
||||||
res := AuditBatchResult{Items: []AuditEvent{}}
|
|
||||||
windowHasKept := false
|
|
||||||
for budget := maxAuditPages; budget > 0 && len(res.Items) < limit; budget-- {
|
|
||||||
items, next, err := listAuditPage(ctx, ac, cred.TenancyOCID, cur)
|
|
||||||
if err != nil {
|
|
||||||
return AuditBatchResult{}, err
|
|
||||||
}
|
|
||||||
before := len(res.Items)
|
|
||||||
res.Items = appendKeptAuditEvents(res.Items, items)
|
|
||||||
windowHasKept = windowHasKept || len(res.Items) > before
|
|
||||||
if next != "" {
|
|
||||||
cur.Page = next
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
nextCur, done := cur.advance(time.Now(), !windowHasKept)
|
|
||||||
if done {
|
|
||||||
res.Exhausted = true
|
|
||||||
sortAuditEvents(res.Items)
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
cur, windowHasKept = nextCur, false
|
|
||||||
}
|
|
||||||
sortAuditEvents(res.Items)
|
|
||||||
res.Cursor = &cur
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// listAuditPage 拉取当前游标位置的一页原始事件。
|
|
||||||
func listAuditPage(ctx context.Context, ac audit.AuditClient, tenancyOCID string, cur AuditCursor) ([]audit.AuditEvent, string, error) {
|
|
||||||
req := audit.ListEventsRequest{
|
req := audit.ListEventsRequest{
|
||||||
CompartmentId: &tenancyOCID,
|
CompartmentId: &tenancyOCID,
|
||||||
StartTime: &common.SDKTime{Time: cur.Start.UTC().Truncate(time.Minute)},
|
StartTime: &common.SDKTime{Time: cur.Start.UTC().Truncate(time.Minute)},
|
||||||
@@ -214,42 +399,18 @@ func listAuditPage(ctx context.Context, ac audit.AuditClient, tenancyOCID string
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("list audit events: %w", err)
|
return nil, "", fmt.Errorf("list audit events: %w", err)
|
||||||
}
|
}
|
||||||
return resp.Items, deref(resp.OpcNextPage), nil
|
items := make([]AuditEvent, 0, len(resp.Items))
|
||||||
|
for _, ev := range resp.Items {
|
||||||
|
out := toAuditEvent(ev)
|
||||||
|
if raw, mErr := json.Marshal(ev); mErr == nil {
|
||||||
|
out.Raw = raw
|
||||||
|
}
|
||||||
|
items = append(items, out)
|
||||||
|
}
|
||||||
|
return items, deref(resp.OpcNextPage), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// auditInternalCIDRs 是 OCI 服务内部互调的发起方网段(RFC1918 + CGNAT)。
|
// toAuditEvent 把 Audit SDK 事件压平为列表 DTO;SDK 字段全为指针,逐层判 nil。
|
||||||
var auditInternalCIDRs = func() []*net.IPNet {
|
|
||||||
out := make([]*net.IPNet, 0, 4)
|
|
||||||
for _, cidr := range []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10"} {
|
|
||||||
_, block, _ := net.ParseCIDR(cidr)
|
|
||||||
out = append(out, block)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}()
|
|
||||||
|
|
||||||
// keepAuditEvent 保留有展示价值的事件:Audit API 无服务端过滤参数(仅时间窗),
|
|
||||||
// 在翻页循环内排除高频遥测噪声(SummarizeMetricsData)与内网地址发起的服务互调;
|
|
||||||
// 无 IP 的事件(控制面内部)保留。
|
|
||||||
func keepAuditEvent(ev AuditEvent) bool {
|
|
||||||
if ev.EventName == "SummarizeMetricsData" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if ev.IPAddress == "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
ip := net.ParseIP(ev.IPAddress)
|
|
||||||
if ip == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, block := range auditInternalCIDRs {
|
|
||||||
if block.Contains(ip) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// toAuditEvent 把 SDK 审计事件压平为列表 DTO;SDK 字段全为指针,逐层判 nil。
|
|
||||||
func toAuditEvent(ev audit.AuditEvent) AuditEvent {
|
func toAuditEvent(ev audit.AuditEvent) AuditEvent {
|
||||||
out := AuditEvent{EventId: deref(ev.EventId), Source: deref(ev.Source)}
|
out := AuditEvent{EventId: deref(ev.EventId), Source: deref(ev.Source)}
|
||||||
if ev.EventTime != nil {
|
if ev.EventTime != nil {
|
||||||
@@ -275,6 +436,130 @@ func toAuditEvent(ev audit.AuditEvent) AuditEvent {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// searchAuditPage 拉取游标窗口内按 datetime 倒序的一页审计事件(已映射未过滤)。
|
||||||
|
func searchAuditPage(ctx context.Context, sc loggingsearch.LogSearchClient, tenancyOCID string, cur AuditCursor) ([]AuditEvent, string, error) {
|
||||||
|
req := loggingsearch.SearchLogsRequest{
|
||||||
|
SearchLogsDetails: loggingsearch.SearchLogsDetails{
|
||||||
|
TimeStart: &common.SDKTime{Time: cur.Start.UTC().Truncate(time.Minute)},
|
||||||
|
TimeEnd: &common.SDKTime{Time: cur.End.UTC().Truncate(time.Minute)},
|
||||||
|
SearchQuery: common.String(auditSearchQuery(tenancyOCID, cur.Q)),
|
||||||
|
},
|
||||||
|
Limit: common.Int(auditSearchPageLimit),
|
||||||
|
}
|
||||||
|
if cur.Page != "" {
|
||||||
|
req.Page = &cur.Page
|
||||||
|
}
|
||||||
|
resp, err := sc.SearchLogs(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("search audit logs: %w", err)
|
||||||
|
}
|
||||||
|
items := make([]AuditEvent, 0, len(resp.Results))
|
||||||
|
for _, r := range resp.Results {
|
||||||
|
if ev, ok := toSearchAuditEvent(r); ok {
|
||||||
|
items = append(items, ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items, deref(resp.OpcNextPage), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditInternalCIDRs 是 OCI 服务内部互调的发起方网段(RFC1918 + CGNAT)。
|
||||||
|
var auditInternalCIDRs = func() []*net.IPNet {
|
||||||
|
out := make([]*net.IPNet, 0, 4)
|
||||||
|
for _, cidr := range []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10"} {
|
||||||
|
_, block, _ := net.ParseCIDR(cidr)
|
||||||
|
out = append(out, block)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}()
|
||||||
|
|
||||||
|
// keepAuditEvent 保留有展示价值的事件:SummarizeMetricsData 已在检索语句里
|
||||||
|
// 先滤(此处兜底),内网地址发起的服务互调用 CIDR 判断(查询语言不便表达);
|
||||||
|
// 无 IP 的事件(控制面内部)保留。
|
||||||
|
func keepAuditEvent(ev AuditEvent) bool {
|
||||||
|
if ev.EventName == "SummarizeMetricsData" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if ev.IPAddress == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(ev.IPAddress)
|
||||||
|
if ip == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, block := range auditInternalCIDRs {
|
||||||
|
if block.Contains(ip) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchAuditContent 是 _Audit 日志 logContent 的字段投影,只取列表展示所需;
|
||||||
|
// identity / request / response 可能为 null,零值即缺省。
|
||||||
|
type searchAuditContent struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Time *time.Time `json:"time"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Data struct {
|
||||||
|
EventName string `json:"eventName"`
|
||||||
|
ResourceName string `json:"resourceName"`
|
||||||
|
CompartmentName string `json:"compartmentName"`
|
||||||
|
Identity struct {
|
||||||
|
PrincipalName string `json:"principalName"`
|
||||||
|
IPAddress string `json:"ipAddress"`
|
||||||
|
} `json:"identity"`
|
||||||
|
Request struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
} `json:"request"`
|
||||||
|
Response struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
} `json:"response"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// toSearchAuditEvent 把日志搜索结果压平为列表 DTO;Raw 即 logContent 原文。
|
||||||
|
// 结构不符的条目丢弃(返回 false),不因单条脏数据整页失败。
|
||||||
|
func toSearchAuditEvent(r loggingsearch.SearchResult) (AuditEvent, bool) {
|
||||||
|
if r.Data == nil {
|
||||||
|
return AuditEvent{}, false
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(r.Data)
|
||||||
|
if err != nil {
|
||||||
|
return AuditEvent{}, false
|
||||||
|
}
|
||||||
|
var hit struct {
|
||||||
|
LogContent json.RawMessage `json:"logContent"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &hit); err != nil || len(hit.LogContent) == 0 {
|
||||||
|
return AuditEvent{}, false
|
||||||
|
}
|
||||||
|
var content searchAuditContent
|
||||||
|
if err := json.Unmarshal(hit.LogContent, &content); err != nil {
|
||||||
|
return AuditEvent{}, false
|
||||||
|
}
|
||||||
|
ev := searchContentToEvent(content)
|
||||||
|
ev.Raw = hit.LogContent
|
||||||
|
return ev, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchContentToEvent 把投影字段填入列表 DTO;Raw 由调用方设置。
|
||||||
|
func searchContentToEvent(c searchAuditContent) AuditEvent {
|
||||||
|
return AuditEvent{
|
||||||
|
EventId: c.ID,
|
||||||
|
EventTime: c.Time,
|
||||||
|
EventName: c.Data.EventName,
|
||||||
|
Source: c.Source,
|
||||||
|
ResourceName: c.Data.ResourceName,
|
||||||
|
CompartmentName: c.Data.CompartmentName,
|
||||||
|
PrincipalName: c.Data.Identity.PrincipalName,
|
||||||
|
IPAddress: c.Data.Identity.IPAddress,
|
||||||
|
Status: c.Data.Response.Status,
|
||||||
|
RequestAction: c.Data.Request.Action,
|
||||||
|
RequestPath: c.Data.Request.Path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// sortAuditEvents 按发生时间倒序排列;服务端返回顺序不保证,nil 时间排最后。
|
// sortAuditEvents 按发生时间倒序排列;服务端返回顺序不保证,nil 时间排最后。
|
||||||
func sortAuditEvents(items []AuditEvent) {
|
func sortAuditEvents(items []AuditEvent) {
|
||||||
sort.SliceStable(items, func(i, j int) bool {
|
sort.SliceStable(items, func(i, j int) bool {
|
||||||
|
|||||||
+294
-27
@@ -1,14 +1,262 @@
|
|||||||
package oci
|
package oci
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/oracle/oci-go-sdk/v65/audit"
|
"github.com/oracle/oci-go-sdk/v65/audit"
|
||||||
"github.com/oracle/oci-go-sdk/v65/common"
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/loggingsearch"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// quotaZeroErr 复刻 Search 配额为零租户的真实报错(SDK 解析错误体失败后带原文)。
|
||||||
|
var quotaZeroErr = errors.New(`search audit logs: Failed to parse json from response body due to: json: cannot unmarshal number into Go struct field servicefailure.code of type string. With response body { "code" : 500, "message" : "Rate limit exceeded for ocid: ocid1.tenancy..x, maxQueriesPerMinute: 0, maxConcurrentQueries: 0" }.`)
|
||||||
|
|
||||||
|
func TestIsSearchQuotaZero(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"配额为零真实报错", quotaZeroErr, true},
|
||||||
|
{"普通限流不回退", errors.New(`Rate limit exceeded for ocid: x, maxQueriesPerMinute: 60, maxConcurrentQueries: 2`), false},
|
||||||
|
{"其他错误", errors.New("service unavailable"), false},
|
||||||
|
{"nil", nil, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := isSearchQuotaZero(tc.err); got != tc.want {
|
||||||
|
t.Fatalf("isSearchQuotaZero() = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAuditBatchFallback(t *testing.T) {
|
||||||
|
et := time.Now().UTC().Add(-10 * time.Minute)
|
||||||
|
searchCalls, auditCalls := 0, 0
|
||||||
|
f := auditFetchers{
|
||||||
|
search: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
|
||||||
|
searchCalls++
|
||||||
|
return nil, "", quotaZeroErr
|
||||||
|
},
|
||||||
|
audit: func(_ context.Context, cur AuditCursor) ([]AuditEvent, string, error) {
|
||||||
|
auditCalls++
|
||||||
|
if cur.M != auditModeFallback || cur.Page != "" {
|
||||||
|
t.Fatalf("回退通道应携带模式标记且清空页游标, got %+v", cur)
|
||||||
|
}
|
||||||
|
ev := AuditEvent{EventId: fmt.Sprint(auditCalls), EventName: "GetInstance", EventTime: &et}
|
||||||
|
return []AuditEvent{ev}, "", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
res, err := listAuditBatch(context.Background(), f, NewAuditCursor(time.Now()), 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("配额为零应回退成功, got %v", err)
|
||||||
|
}
|
||||||
|
if searchCalls != 1 {
|
||||||
|
t.Fatalf("Search 只应试错一次, got %d", searchCalls)
|
||||||
|
}
|
||||||
|
if len(res.Items) < 3 || auditCalls < 3 {
|
||||||
|
t.Fatalf("回退后应继续凑批, items=%d auditCalls=%d", len(res.Items), auditCalls)
|
||||||
|
}
|
||||||
|
if res.Cursor == nil || res.Cursor.M != auditModeFallback || res.Cursor.WindowHours != auditFallbackWindowHours {
|
||||||
|
t.Fatalf("续查游标应保持回退模式与基准窗宽, got %+v", res.Cursor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAuditBatchFallbackCursorSkipsSearch(t *testing.T) {
|
||||||
|
et := time.Now().UTC().Add(-10 * time.Minute)
|
||||||
|
f := auditFetchers{
|
||||||
|
search: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
|
||||||
|
t.Fatal("回退模式游标不应再调用 Search 通道")
|
||||||
|
return nil, "", nil
|
||||||
|
},
|
||||||
|
audit: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
|
||||||
|
return []AuditEvent{{EventId: "e1", EventName: "GetVcn", EventTime: &et}}, "", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cur := NewAuditCursor(time.Now()).toFallback()
|
||||||
|
if _, err := listAuditBatch(context.Background(), f, cur, 1); err != nil {
|
||||||
|
t.Fatalf("回退模式续查失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAuditBatchSearchErrorNoFallback(t *testing.T) {
|
||||||
|
f := auditFetchers{
|
||||||
|
search: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
|
||||||
|
return nil, "", errors.New("search audit logs: timeout")
|
||||||
|
},
|
||||||
|
audit: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
|
||||||
|
t.Fatal("普通错误不应触发回退")
|
||||||
|
return nil, "", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if _, err := listAuditBatch(context.Background(), f, NewAuditCursor(time.Now()), 1); err == nil {
|
||||||
|
t.Fatal("普通错误应原样上抛")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterAuditTerm(t *testing.T) {
|
||||||
|
login := AuditEvent{EventId: "e1", EventName: "InteractiveLogin"}
|
||||||
|
noise := AuditEvent{EventId: "e2", EventName: "ListRecommendations"}
|
||||||
|
items := []AuditEvent{login, noise}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cur AuditCursor
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"无关键字原样放行", AuditCursor{}, 2},
|
||||||
|
{"Search 主路也精筛可见字段", AuditCursor{Q: "login"}, 1},
|
||||||
|
{"回退模式精筛", AuditCursor{Q: "login", M: auditModeFallback}, 1},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := filterAuditTerm(append([]AuditEvent{}, items...), tc.cur); len(got) != tc.want {
|
||||||
|
t.Fatalf("filterAuditTerm() 保留 %d 条, want %d", len(got), tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAuditBatchSearchTermPrecision(t *testing.T) {
|
||||||
|
et := time.Now().UTC().Add(-10 * time.Minute)
|
||||||
|
// 模拟 Search 主路粗筛后仍混入的隐藏元数据误命中(如 ttype:login)
|
||||||
|
f := auditFetchers{
|
||||||
|
search: func(_ context.Context, cur AuditCursor) ([]AuditEvent, string, error) {
|
||||||
|
return []AuditEvent{
|
||||||
|
{EventId: "hit", EventName: "InteractiveLogin", EventTime: &et},
|
||||||
|
{EventId: "noise1", EventName: "ListRecommendations", EventTime: &et},
|
||||||
|
{EventId: "noise2", EventName: "SearchLogs", EventTime: &et},
|
||||||
|
}, "", nil
|
||||||
|
},
|
||||||
|
audit: func(context.Context, AuditCursor) ([]AuditEvent, string, error) {
|
||||||
|
t.Fatal("Search 正常时不应走回退")
|
||||||
|
return nil, "", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cur := NewAuditCursor(time.Now())
|
||||||
|
cur.Q = "login"
|
||||||
|
res, err := listAuditBatch(context.Background(), f, cur, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listAuditBatch() err = %v", err)
|
||||||
|
}
|
||||||
|
if len(res.Items) != 1 || res.Items[0].EventId != "hit" {
|
||||||
|
t.Fatalf("应只保留可见字段命中的事件, got %+v", res.Items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchesAuditTerm(t *testing.T) {
|
||||||
|
ev := AuditEvent{EventName: "ListVnicAttachments", ResourceName: "web-1", PrincipalName: "Vivien", IPAddress: "1.2.3.4"}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
q string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"不区分大小写", "listvnic", true},
|
||||||
|
{"通配分段都出现", "List*Attachments", true},
|
||||||
|
{"资源名命中", "WEB-1", true},
|
||||||
|
{"未命中", "TerminateInstance", false},
|
||||||
|
{"通配缺段不命中", "List*Volume", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := matchesAuditTerm(ev, tc.q); got != tc.want {
|
||||||
|
t.Fatalf("matchesAuditTerm(%q) = %v, want %v", tc.q, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchResultFromJSON 把 JSON 文本构造成 SearchLogs 单条结果(Data 为 interface{})。
|
||||||
|
func searchResultFromJSON(t *testing.T, s string) loggingsearch.SearchResult {
|
||||||
|
t.Helper()
|
||||||
|
var v interface{}
|
||||||
|
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||||
|
t.Fatalf("fixture 不是合法 JSON: %v", err)
|
||||||
|
}
|
||||||
|
return loggingsearch.SearchResult{Data: &v}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToSearchAuditEvent(t *testing.T) {
|
||||||
|
eventTime := time.Date(2026, 7, 6, 10, 30, 0, 0, time.UTC)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
data string
|
||||||
|
wantOK bool
|
||||||
|
want AuditEvent
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "全字段齐全",
|
||||||
|
data: `{"datetime":1783074600000,"logContent":{
|
||||||
|
"id":"evt-abc","time":"2026-07-06T10:30:00Z","source":"ComputeApi",
|
||||||
|
"data":{"eventName":"TerminateInstance","resourceName":"web-1","compartmentName":"prod",
|
||||||
|
"identity":{"principalName":"api-admin","ipAddress":"1.2.3.4"},
|
||||||
|
"request":{"action":"DELETE","path":"/20160918/instances/ocid1..."},
|
||||||
|
"response":{"status":"204"}}}}`,
|
||||||
|
wantOK: true,
|
||||||
|
want: AuditEvent{
|
||||||
|
EventId: "evt-abc",
|
||||||
|
EventTime: &eventTime,
|
||||||
|
EventName: "TerminateInstance",
|
||||||
|
Source: "ComputeApi",
|
||||||
|
ResourceName: "web-1",
|
||||||
|
CompartmentName: "prod",
|
||||||
|
PrincipalName: "api-admin",
|
||||||
|
IPAddress: "1.2.3.4",
|
||||||
|
Status: "204",
|
||||||
|
RequestAction: "DELETE",
|
||||||
|
RequestPath: "/20160918/instances/ocid1...",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "identity/request/response 为 null 时只保留信封字段",
|
||||||
|
data: `{"logContent":{"id":"evt-x","time":"2026-07-06T10:30:00Z","source":"VcnApi",
|
||||||
|
"data":{"eventName":"GetVcn","identity":null,"request":null,"response":null}}}`,
|
||||||
|
wantOK: true,
|
||||||
|
want: AuditEvent{EventId: "evt-x", EventTime: &eventTime, Source: "VcnApi", EventName: "GetVcn"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "缺 logContent 丢弃",
|
||||||
|
data: `{"datetime":1783074600000}`,
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "logContent 结构不符丢弃",
|
||||||
|
data: `{"logContent":"plain-text"}`,
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, ok := toSearchAuditEvent(searchResultFromJSON(t, tt.data))
|
||||||
|
if ok != tt.wantOK {
|
||||||
|
t.Fatalf("ok = %v, want %v", ok, tt.wantOK)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(got.Raw) == 0 {
|
||||||
|
t.Fatalf("Raw 应携带 logContent 原文")
|
||||||
|
}
|
||||||
|
if !auditEventEqual(got, tt.want) {
|
||||||
|
t.Errorf("toSearchAuditEvent() = %+v, want %+v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToSearchAuditEventNilData(t *testing.T) {
|
||||||
|
if _, ok := toSearchAuditEvent(loggingsearch.SearchResult{}); ok {
|
||||||
|
t.Fatal("Data 为 nil 应丢弃")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestToAuditEvent(t *testing.T) {
|
func TestToAuditEvent(t *testing.T) {
|
||||||
eventTime := time.Date(2026, 7, 6, 10, 30, 0, 0, time.UTC)
|
eventTime := time.Date(2026, 7, 6, 10, 30, 0, 0, time.UTC)
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -38,44 +286,23 @@ func TestToAuditEvent(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
want: AuditEvent{
|
want: AuditEvent{
|
||||||
EventId: "evt-abc",
|
EventId: "evt-abc", EventTime: &eventTime, EventName: "TerminateInstance",
|
||||||
EventTime: &eventTime,
|
Source: "ComputeApi", ResourceName: "web-1", CompartmentName: "prod",
|
||||||
EventName: "TerminateInstance",
|
PrincipalName: "api-admin", IPAddress: "1.2.3.4", Status: "204",
|
||||||
Source: "ComputeApi",
|
RequestAction: "DELETE", RequestPath: "/20160918/instances/ocid1...",
|
||||||
ResourceName: "web-1",
|
|
||||||
CompartmentName: "prod",
|
|
||||||
PrincipalName: "api-admin",
|
|
||||||
IPAddress: "1.2.3.4",
|
|
||||||
Status: "204",
|
|
||||||
RequestAction: "DELETE",
|
|
||||||
RequestPath: "/20160918/instances/ocid1...",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "Data 为 nil 时只保留信封字段",
|
|
||||||
ev: audit.AuditEvent{
|
|
||||||
Source: common.String("VcnApi"),
|
|
||||||
EventTime: &common.SDKTime{Time: eventTime},
|
|
||||||
},
|
|
||||||
want: AuditEvent{EventTime: &eventTime, Source: "VcnApi"},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "嵌套局部 nil 各自安全跳过",
|
name: "嵌套局部 nil 各自安全跳过",
|
||||||
ev: audit.AuditEvent{
|
ev: audit.AuditEvent{
|
||||||
Data: &audit.Data{
|
Data: &audit.Data{
|
||||||
EventName: common.String("GetInstance"),
|
EventName: common.String("GetInstance"),
|
||||||
Identity: nil,
|
|
||||||
Request: &audit.Request{Path: common.String("/instances")},
|
Request: &audit.Request{Path: common.String("/instances")},
|
||||||
Response: nil,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
want: AuditEvent{EventName: "GetInstance", RequestPath: "/instances"},
|
want: AuditEvent{EventName: "GetInstance", RequestPath: "/instances"},
|
||||||
},
|
},
|
||||||
{
|
{name: "空事件全部零值", ev: audit.AuditEvent{}, want: AuditEvent{}},
|
||||||
name: "空事件全部零值",
|
|
||||||
ev: audit.AuditEvent{},
|
|
||||||
want: AuditEvent{},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -86,6 +313,37 @@ func TestToAuditEvent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuditSearchQuery(t *testing.T) {
|
||||||
|
const prefix = `search "ocid1.tenancy.oc1..aaa/_Audit" | where data.eventName != 'SummarizeMetricsData'`
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
term string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"无关键字", "", prefix + ` | sort by datetime desc`},
|
||||||
|
{"带关键字追加全文匹配", "TerminateInstance", prefix + ` and logContent = '*TerminateInstance*' | sort by datetime desc`},
|
||||||
|
{"引号与反斜杠被消毒", `O'Brien\"x`, prefix + ` and logContent = '*OBrienx*' | sort by datetime desc`},
|
||||||
|
{"纯引号消毒后为空不追加", `'"`, prefix + ` | sort by datetime desc`},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := auditSearchQuery("ocid1.tenancy.oc1..aaa", tc.term); got != tc.want {
|
||||||
|
t.Fatalf("auditSearchQuery() = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeAuditTerm(t *testing.T) {
|
||||||
|
if got := SanitizeAuditTerm(" Get*Instance\t "); got != "Get*Instance" {
|
||||||
|
t.Fatalf("应保留 * 并去除首尾空白与控制字符, got %q", got)
|
||||||
|
}
|
||||||
|
long := strings.Repeat("a", 300)
|
||||||
|
if got := SanitizeAuditTerm(long); len(got) != auditTermMaxLen {
|
||||||
|
t.Fatalf("超长应截断到 %d, got %d", auditTermMaxLen, len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// auditEventEqual 比较两个 DTO:EventTime 按值比较,Raw 不参与,其余反射比较。
|
// auditEventEqual 比较两个 DTO:EventTime 按值比较,Raw 不参与,其余反射比较。
|
||||||
func auditEventEqual(a, b AuditEvent) bool {
|
func auditEventEqual(a, b AuditEvent) bool {
|
||||||
if (a.EventTime == nil) != (b.EventTime == nil) {
|
if (a.EventTime == nil) != (b.EventTime == nil) {
|
||||||
@@ -149,6 +407,7 @@ func TestAuditCursorAdvance(t *testing.T) {
|
|||||||
Start: now.Add(-24 * time.Hour),
|
Start: now.Add(-24 * time.Hour),
|
||||||
End: now,
|
End: now,
|
||||||
WindowHours: 24,
|
WindowHours: 24,
|
||||||
|
Q: "kw",
|
||||||
}
|
}
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -159,8 +418,10 @@ func TestAuditCursorAdvance(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"有事件重置 24h 窗", AuditCursor{Start: base.Start, End: base.End, WindowHours: 96}, false, 24, false},
|
{"有事件重置 24h 窗", AuditCursor{Start: base.Start, End: base.End, WindowHours: 96}, false, 24, false},
|
||||||
{"空窗倍增", base, true, 48, false},
|
{"空窗倍增", base, true, 48, false},
|
||||||
{"倍增封顶 720h", AuditCursor{Start: base.Start, End: base.End, WindowHours: 512}, true, 720, false},
|
{"倍增封顶 336h(14 天查询窗硬限)", AuditCursor{Start: base.Start, End: base.End, WindowHours: 256}, true, 336, false},
|
||||||
{"窗宽缺省按 24h 起算", AuditCursor{Start: base.Start, End: base.End}, true, 48, false},
|
{"窗宽缺省按 24h 起算", AuditCursor{Start: base.Start, End: base.End}, true, 48, false},
|
||||||
|
{"回退模式有事件重置 1h 基准窗", AuditCursor{Start: base.Start, End: base.End, WindowHours: 8, M: auditModeFallback}, false, 1, false},
|
||||||
|
{"回退模式空窗照常倍增", AuditCursor{Start: base.Start, End: base.End, WindowHours: 1, M: auditModeFallback}, true, 2, false},
|
||||||
{"越过保留期即尽头", AuditCursor{Start: now.AddDate(0, 0, -366), End: now.AddDate(0, 0, -365), WindowHours: 24}, false, 0, true},
|
{"越过保留期即尽头", AuditCursor{Start: now.AddDate(0, 0, -366), End: now.AddDate(0, 0, -365), WindowHours: 24}, false, 0, true},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -184,6 +445,12 @@ func TestAuditCursorAdvance(t *testing.T) {
|
|||||||
if next.Page != "" {
|
if next.Page != "" {
|
||||||
t.Fatalf("新窗应清空窗内游标, got %q", next.Page)
|
t.Fatalf("新窗应清空窗内游标, got %q", next.Page)
|
||||||
}
|
}
|
||||||
|
if next.Q != tc.cur.Q {
|
||||||
|
t.Fatalf("新窗应继承检索关键字, got %q want %q", next.Q, tc.cur.Q)
|
||||||
|
}
|
||||||
|
if next.M != tc.cur.M {
|
||||||
|
t.Fatalf("新窗应继承通道模式, got %q want %q", next.M, tc.cur.M)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,10 +102,12 @@ type Client interface {
|
|||||||
ListGenAiModels(ctx context.Context, cred Credentials, region string) ([]GenAiModel, error)
|
ListGenAiModels(ctx context.Context, cred Credentials, region string) ([]GenAiModel, error)
|
||||||
GenAiProbeChat(ctx context.Context, cred Credentials, region, modelOcid, modelName string) (int, error)
|
GenAiProbeChat(ctx context.Context, cred Credentials, region, modelOcid, modelName string) (int, error)
|
||||||
GenAiEmbed(ctx context.Context, cred Credentials, region, modelOcid string, inputs []string, dimensions *int) ([][]float32, *aiwire.Usage, error)
|
GenAiEmbed(ctx context.Context, cred Credentials, region, modelOcid string, inputs []string, dimensions *int) ([][]float32, *aiwire.Usage, error)
|
||||||
// GenAiCompatResponses 直通 OpenAI Responses 请求体到 /actions/v1/responses(xAI 服务端工具通路)。
|
// GenAiCompatResponses 直通 OpenAI Responses 请求体到 /actions/v1/responses(xAI 服务端工具通路);
|
||||||
GenAiCompatResponses(ctx context.Context, cred Credentials, region string, body []byte) ([]byte, error)
|
// wait 是上游无响应预算(整请求总超时),multi-agent/搜索类模型需远超 SDK 默认 60s。
|
||||||
// GenAiCompatResponsesStream 流式直通 /actions/v1/responses,建立成功返回 SSE body。
|
GenAiCompatResponses(ctx context.Context, cred Credentials, region string, body []byte, wait time.Duration) ([]byte, error)
|
||||||
GenAiCompatResponsesStream(ctx context.Context, cred Credentials, region string, body []byte) (io.ReadCloser, error)
|
// GenAiCompatResponsesStream 流式直通 /actions/v1/responses,建立成功返回 SSE body;
|
||||||
|
// wait 仅约束等待响应头阶段,建立后的流生命周期由 ctx 决定。
|
||||||
|
GenAiCompatResponsesStream(ctx context.Context, cred Credentials, region string, body []byte, wait time.Duration) (io.ReadCloser, error)
|
||||||
// GenAiCompatSpeech 直通 OpenAI Audio Speech 请求体到 /openai/v1/audio/speech,返回音频与 Content-Type。
|
// GenAiCompatSpeech 直通 OpenAI Audio Speech 请求体到 /openai/v1/audio/speech,返回音频与 Content-Type。
|
||||||
GenAiCompatSpeech(ctx context.Context, cred Credentials, region string, body []byte) ([]byte, string, error)
|
GenAiCompatSpeech(ctx context.Context, cred Credentials, region string, body []byte) ([]byte, string, error)
|
||||||
// GenAiRerank 文档重排,返回按相关度排序的下标与得分。
|
// GenAiRerank 文档重排,返回按相关度排序的下标与得分。
|
||||||
|
|||||||
@@ -158,7 +158,8 @@ func (c *RealClient) GenAiProbeChat(ctx context.Context, cred Credentials, regio
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if _, err = c.GenAiCompatResponses(ctx, cred, region, body); err == nil {
|
// 探测追求快速失败,沿用 SDK 默认量级的 60s 预算即可
|
||||||
|
if _, err = c.GenAiCompatResponses(ctx, cred, region, body, 60*time.Second); err == nil {
|
||||||
return http.StatusOK, nil
|
return http.StatusOK, nil
|
||||||
}
|
}
|
||||||
if status, ok := ServiceStatus(err); ok {
|
if status, ok := ServiceStatus(err); ok {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/oracle/oci-go-sdk/v65/common"
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
)
|
)
|
||||||
@@ -13,18 +14,19 @@ import (
|
|||||||
// compatResponsesLimit 限制直通响应体大小;web_search 输出含多段引用,给足余量。
|
// compatResponsesLimit 限制直通响应体大小;web_search 输出含多段引用,给足余量。
|
||||||
const compatResponsesLimit = int64(8 << 20)
|
const compatResponsesLimit = int64(8 << 20)
|
||||||
|
|
||||||
// GenAiCompatResponses 实现 Client:把 OpenAI Responses 请求体直通到 OCI
|
// dispatcherWithTimeout 把 dispatcher 换成指定总超时的拷贝(保留 Transport,
|
||||||
// `/20231130/actions/v1/responses`(IAM 签名)。xAI 服务端工具(web_search /
|
// 代理链路不受影响);timeout=0 表示无总超时(流式读 body 不能有总时限)。
|
||||||
// x_search / code_interpreter)与 mcp 已被 Oracle 文档正式支持,工具参数与限制
|
// 非 *http.Client 的自定义 dispatcher 保持原样,维持既有超时行为。
|
||||||
// 遵循 xAI 规格;调用方须自行校验并改写请求体(store/stream)。
|
func dispatcherWithTimeout(d common.HTTPRequestDispatcher, timeout time.Duration) common.HTTPRequestDispatcher {
|
||||||
func (c *RealClient) GenAiCompatResponses(ctx context.Context, cred Credentials, region string, body []byte) ([]byte, error) {
|
hc, ok := d.(*http.Client)
|
||||||
ic, err := c.genAiInferenceClient(cred, region)
|
if !ok {
|
||||||
if err != nil {
|
return d
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
client := ic.BaseClient
|
return &http.Client{Transport: hc.Transport, Timeout: timeout}
|
||||||
common.UpdateEndpointTemplateForOptions(&client)
|
}
|
||||||
common.SetMissingTemplateParams(&client)
|
|
||||||
|
// newCompatResponsesRequest 构造 /actions/v1/responses 直通请求。
|
||||||
|
func newCompatResponsesRequest(ctx context.Context, cred Credentials, body []byte) (*http.Request, error) {
|
||||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "/actions/v1/responses", bytes.NewReader(body))
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "/actions/v1/responses", bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("build compat responses request: %w", err)
|
return nil, fmt.Errorf("build compat responses request: %w", err)
|
||||||
@@ -32,6 +34,27 @@ func (c *RealClient) GenAiCompatResponses(ctx context.Context, cred Credentials,
|
|||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
request.Header.Set("CompartmentId", cred.TenancyOCID)
|
request.Header.Set("CompartmentId", cred.TenancyOCID)
|
||||||
request.Header.Set("opc-compartment-id", cred.TenancyOCID)
|
request.Header.Set("opc-compartment-id", cred.TenancyOCID)
|
||||||
|
return request, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenAiCompatResponses 实现 Client:把 OpenAI Responses 请求体直通到 OCI
|
||||||
|
// `/20231130/actions/v1/responses`(IAM 签名)。xAI 服务端工具(web_search /
|
||||||
|
// x_search / code_interpreter)与 mcp 已被 Oracle 文档正式支持,工具参数与限制
|
||||||
|
// 遵循 xAI 规格;调用方须自行校验并改写请求体(store/stream)。
|
||||||
|
// wait 为整请求总超时:非流式上游要等全部生成完才回响应头,SDK 默认 60s 会掐断慢模型。
|
||||||
|
func (c *RealClient) GenAiCompatResponses(ctx context.Context, cred Credentials, region string, body []byte, wait time.Duration) ([]byte, error) {
|
||||||
|
ic, err := c.genAiInferenceClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
client := ic.BaseClient
|
||||||
|
common.UpdateEndpointTemplateForOptions(&client)
|
||||||
|
common.SetMissingTemplateParams(&client)
|
||||||
|
client.HTTPClient = dispatcherWithTimeout(client.HTTPClient, wait)
|
||||||
|
request, err := newCompatResponsesRequest(ctx, cred, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
response, err := client.Call(ctx, request)
|
response, err := client.Call(ctx, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -44,10 +67,46 @@ func (c *RealClient) GenAiCompatResponses(ctx context.Context, cred Credentials,
|
|||||||
return payload, nil
|
return payload, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cancelReadCloser 在流关闭时同步取消建立阶段派生的 ctx,避免其随流生命周期泄漏。
|
||||||
|
type cancelReadCloser struct {
|
||||||
|
io.ReadCloser
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cancelReadCloser) Close() error {
|
||||||
|
c.cancel()
|
||||||
|
return c.ReadCloser.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpCaller 抽象 BaseClient.Call,便于对预算逻辑做无签名单测。
|
||||||
|
type httpCaller interface {
|
||||||
|
Call(ctx context.Context, request *http.Request) (*http.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// callWithHeaderBudget 以 wait 为等待响应头预算执行调用:预算内未返回则取消
|
||||||
|
// 请求(SDK Call 会把 ctx 重绑到请求);响应头到达即解除预算,之后流的生命
|
||||||
|
// 周期由 ctx 决定,返回的流 Close 时同步取消派生 ctx。
|
||||||
|
func callWithHeaderBudget(ctx context.Context, c httpCaller, req *http.Request, wait time.Duration) (io.ReadCloser, error) {
|
||||||
|
callCtx, cancel := context.WithCancel(ctx)
|
||||||
|
timer := time.AfterFunc(wait, cancel)
|
||||||
|
response, err := c.Call(callCtx, req)
|
||||||
|
timer.Stop()
|
||||||
|
if err != nil {
|
||||||
|
if response != nil && response.Body != nil {
|
||||||
|
response.Body.Close()
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cancelReadCloser{ReadCloser: response.Body, cancel: cancel}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GenAiCompatResponsesStream 实现 Client:以流式直通 OCI `/actions/v1/responses`,
|
// GenAiCompatResponsesStream 实现 Client:以流式直通 OCI `/actions/v1/responses`,
|
||||||
// 建立成功(2xx)返回 SSE body(调用方负责 Close);建立失败返回 SDK ServiceError,
|
// 建立成功(2xx)返回 SSE body(调用方负责 Close);建立失败返回 SDK ServiceError,
|
||||||
// 与既有渠道切换/熔断错误分类兼容。请求体须由调用方置 stream:true。
|
// 与既有渠道切换/熔断错误分类兼容。请求体须由调用方置 stream:true。
|
||||||
func (c *RealClient) GenAiCompatResponsesStream(ctx context.Context, cred Credentials, region string, body []byte) (io.ReadCloser, error) {
|
// 总超时置 0(SSE 读 body 不能有总时限);wait 以定时取消模拟等待响应头预算,
|
||||||
|
// 响应头到达即解除,此后流的生命周期完全由 ctx(下游客户端断开)决定。
|
||||||
|
func (c *RealClient) GenAiCompatResponsesStream(ctx context.Context, cred Credentials, region string, body []byte, wait time.Duration) (io.ReadCloser, error) {
|
||||||
ic, err := c.genAiInferenceClient(cred, region)
|
ic, err := c.genAiInferenceClient(cred, region)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -55,19 +114,10 @@ func (c *RealClient) GenAiCompatResponsesStream(ctx context.Context, cred Creden
|
|||||||
client := ic.BaseClient
|
client := ic.BaseClient
|
||||||
common.UpdateEndpointTemplateForOptions(&client)
|
common.UpdateEndpointTemplateForOptions(&client)
|
||||||
common.SetMissingTemplateParams(&client)
|
common.SetMissingTemplateParams(&client)
|
||||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "/actions/v1/responses", bytes.NewReader(body))
|
client.HTTPClient = dispatcherWithTimeout(client.HTTPClient, 0)
|
||||||
|
request, err := newCompatResponsesRequest(ctx, cred, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("build compat responses stream request: %w", err)
|
|
||||||
}
|
|
||||||
request.Header.Set("Content-Type", "application/json")
|
|
||||||
request.Header.Set("CompartmentId", cred.TenancyOCID)
|
|
||||||
request.Header.Set("opc-compartment-id", cred.TenancyOCID)
|
|
||||||
response, err := client.Call(ctx, request)
|
|
||||||
if err != nil {
|
|
||||||
if response != nil && response.Body != nil {
|
|
||||||
response.Body.Close()
|
|
||||||
}
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return response.Body, nil
|
return callWithHeaderBudget(ctx, client, request, wait)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// staticDispatcher 是非 *http.Client 的自定义 dispatcher,用于降级分支。
|
||||||
|
type staticDispatcher struct{}
|
||||||
|
|
||||||
|
func (staticDispatcher) Do(*http.Request) (*http.Response, error) { return nil, nil }
|
||||||
|
|
||||||
|
func TestDispatcherWithTimeout(t *testing.T) {
|
||||||
|
tr := &http.Transport{}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in common.HTTPRequestDispatcher
|
||||||
|
timeout time.Duration
|
||||||
|
check func(t *testing.T, out common.HTTPRequestDispatcher)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "http.Client 换总超时并保留 Transport", in: &http.Client{Transport: tr, Timeout: 60 * time.Second},
|
||||||
|
timeout: 300 * time.Second,
|
||||||
|
check: func(t *testing.T, out common.HTTPRequestDispatcher) {
|
||||||
|
hc, ok := out.(*http.Client)
|
||||||
|
if !ok || hc.Timeout != 300*time.Second || hc.Transport != tr {
|
||||||
|
t.Fatalf("期望拷贝 client 且 Timeout=300s、Transport 保留, 得到 %#v", out)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "timeout=0 表示无总超时", in: &http.Client{Timeout: 60 * time.Second}, timeout: 0,
|
||||||
|
check: func(t *testing.T, out common.HTTPRequestDispatcher) {
|
||||||
|
if hc := out.(*http.Client); hc.Timeout != 0 {
|
||||||
|
t.Fatalf("期望 Timeout=0, 得到 %v", hc.Timeout)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "非 http.Client 原样返回", in: staticDispatcher{}, timeout: 300 * time.Second,
|
||||||
|
check: func(t *testing.T, out common.HTTPRequestDispatcher) {
|
||||||
|
if _, ok := out.(staticDispatcher); !ok {
|
||||||
|
t.Fatalf("期望原样返回自定义 dispatcher, 得到 %#v", out)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) { tt.check(t, dispatcherWithTimeout(tt.in, tt.timeout)) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ctxReader 模拟真实 HTTP body:请求 ctx 取消后读即失败。
|
||||||
|
type ctxReader struct {
|
||||||
|
ctx context.Context
|
||||||
|
r io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ctxReader) Read(p []byte) (int, error) {
|
||||||
|
if err := c.ctx.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return c.r.Read(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeCaller 在 delay 后返回响应;期间 ctx 取消则按真实行为返回 ctx 错误。
|
||||||
|
type fakeCaller struct {
|
||||||
|
delay time.Duration
|
||||||
|
body string
|
||||||
|
gotCtx context.Context
|
||||||
|
failErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCaller) Call(ctx context.Context, _ *http.Request) (*http.Response, error) {
|
||||||
|
f.gotCtx = ctx
|
||||||
|
if f.failErr != nil {
|
||||||
|
return nil, f.failErr
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(f.delay):
|
||||||
|
body := &ctxReader{ctx: ctx, r: strings.NewReader(f.body)}
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(body)}, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallWithHeaderBudget(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, "/actions/v1/responses", nil)
|
||||||
|
t.Run("预算内返回响应头后长读不受预算影响", func(t *testing.T) {
|
||||||
|
fc := &fakeCaller{delay: 0, body: "data: hello"}
|
||||||
|
stream, err := callWithHeaderBudget(context.Background(), fc, req, 30*time.Millisecond)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("callWithHeaderBudget = %v", err)
|
||||||
|
}
|
||||||
|
defer stream.Close()
|
||||||
|
time.Sleep(90 * time.Millisecond) // 远超预算,验证响应头到达后预算已解除
|
||||||
|
payload, err := io.ReadAll(stream)
|
||||||
|
if err != nil || string(payload) != "data: hello" {
|
||||||
|
t.Fatalf("预算解除后读流 = %q, %v; 期望完整 body", payload, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("超预算未返回响应头即取消", func(t *testing.T) {
|
||||||
|
fc := &fakeCaller{delay: time.Minute}
|
||||||
|
start := time.Now()
|
||||||
|
_, err := callWithHeaderBudget(context.Background(), fc, req, 30*time.Millisecond)
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("期望 context.Canceled, 得到 %v", err)
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(start); elapsed > 5*time.Second {
|
||||||
|
t.Fatalf("取消耗时 %v, 未受预算约束", elapsed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("关闭流时取消派生 ctx", func(t *testing.T) {
|
||||||
|
fc := &fakeCaller{delay: 0, body: "x"}
|
||||||
|
stream, err := callWithHeaderBudget(context.Background(), fc, req, time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("callWithHeaderBudget = %v", err)
|
||||||
|
}
|
||||||
|
stream.Close()
|
||||||
|
if fc.gotCtx.Err() == nil {
|
||||||
|
t.Fatal("Close 后派生 ctx 应已取消")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("建立失败时同样取消派生 ctx", func(t *testing.T) {
|
||||||
|
fc := &fakeCaller{failErr: errors.New("boom")}
|
||||||
|
if _, err := callWithHeaderBudget(context.Background(), fc, req, time.Minute); err == nil {
|
||||||
|
t.Fatal("期望建立失败")
|
||||||
|
}
|
||||||
|
if fc.gotCtx.Err() == nil {
|
||||||
|
t.Fatal("失败路径派生 ctx 应已取消")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -400,6 +400,7 @@ func fillInstanceIPs(ctx context.Context, cc core.ComputeClient, vn core.Virtual
|
|||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
sem = make(chan struct{}, 8)
|
sem = make(chan struct{}, 8)
|
||||||
|
primarySeen = make(map[*Instance]bool)
|
||||||
)
|
)
|
||||||
for _, att := range attResp.Items {
|
for _, att := range attResp.Items {
|
||||||
inst, ok := active[deref(att.InstanceId)]
|
inst, ok := active[deref(att.InstanceId)]
|
||||||
@@ -416,16 +417,30 @@ func fillInstanceIPs(ctx context.Context, cc core.ComputeClient, vn core.Virtual
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
inst.SubnetID = deref(vnicResp.SubnetId)
|
applyVnicAddrs(inst, vnicResp.Vnic, primarySeen)
|
||||||
inst.PrivateIP = deref(vnicResp.PrivateIp)
|
|
||||||
inst.PublicIP = deref(vnicResp.PublicIp)
|
|
||||||
inst.Ipv6Addresses = vnicResp.Ipv6Addresses
|
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
}(att.VnicId, inst)
|
}(att.VnicId, inst)
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyVnicAddrs 将 VNIC 地址写入实例。多网卡实例以主网卡为准:
|
||||||
|
// 主网卡返回前先用先到的网卡兜底,主网卡到达后覆盖并锁定,避免并发
|
||||||
|
// 完成顺序决定展示结果。调用方需持有保护 inst 与 primarySeen 的锁。
|
||||||
|
func applyVnicAddrs(inst *Instance, v core.Vnic, primarySeen map[*Instance]bool) {
|
||||||
|
isPrimary := v.IsPrimary != nil && *v.IsPrimary
|
||||||
|
if !isPrimary && (primarySeen[inst] || inst.SubnetID != "") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inst.SubnetID = deref(v.SubnetId)
|
||||||
|
inst.PrivateIP = deref(v.PrivateIp)
|
||||||
|
inst.PublicIP = deref(v.PublicIp)
|
||||||
|
inst.Ipv6Addresses = v.Ipv6Addresses
|
||||||
|
if isPrimary {
|
||||||
|
primarySeen[inst] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func toInstance(inst core.Instance) Instance {
|
func toInstance(inst core.Instance) Instance {
|
||||||
out := Instance{
|
out := Instance{
|
||||||
ID: deref(inst.Id),
|
ID: deref(inst.Id),
|
||||||
|
|||||||
@@ -5,9 +5,51 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
"github.com/oracle/oci-go-sdk/v65/core"
|
"github.com/oracle/oci-go-sdk/v65/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestApplyVnicAddrs(t *testing.T) {
|
||||||
|
primary := core.Vnic{
|
||||||
|
IsPrimary: common.Bool(true),
|
||||||
|
SubnetId: common.String("sub-a"),
|
||||||
|
PrivateIp: common.String("10.0.0.2"),
|
||||||
|
PublicIp: common.String("1.1.1.1"),
|
||||||
|
}
|
||||||
|
secondary := core.Vnic{
|
||||||
|
IsPrimary: common.Bool(false),
|
||||||
|
SubnetId: common.String("sub-b"),
|
||||||
|
PrivateIp: common.String("10.0.0.9"),
|
||||||
|
}
|
||||||
|
secondary2 := core.Vnic{
|
||||||
|
SubnetId: common.String("sub-c"),
|
||||||
|
PrivateIp: common.String("10.0.0.7"),
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
order []core.Vnic
|
||||||
|
wantPrivate string
|
||||||
|
wantSubnet string
|
||||||
|
}{
|
||||||
|
{name: "主卡先到不被次卡覆盖", order: []core.Vnic{primary, secondary}, wantPrivate: "10.0.0.2", wantSubnet: "sub-a"},
|
||||||
|
{name: "次卡先兜底主卡后覆盖", order: []core.Vnic{secondary, primary}, wantPrivate: "10.0.0.2", wantSubnet: "sub-a"},
|
||||||
|
{name: "无主卡时先到者保留", order: []core.Vnic{secondary, secondary2}, wantPrivate: "10.0.0.9", wantSubnet: "sub-b"},
|
||||||
|
{name: "仅次卡也能兜底", order: []core.Vnic{secondary2}, wantPrivate: "10.0.0.7", wantSubnet: "sub-c"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
inst := &Instance{}
|
||||||
|
seen := make(map[*Instance]bool)
|
||||||
|
for _, v := range tt.order {
|
||||||
|
applyVnicAddrs(inst, v, seen)
|
||||||
|
}
|
||||||
|
if inst.PrivateIP != tt.wantPrivate || inst.SubnetID != tt.wantSubnet {
|
||||||
|
t.Errorf("got (%q, %q), want (%q, %q)", inst.PrivateIP, inst.SubnetID, tt.wantPrivate, tt.wantSubnet)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestShellSingleQuote(t *testing.T) {
|
func TestShellSingleQuote(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package oci
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
@@ -23,6 +24,13 @@ type ProxySpec struct {
|
|||||||
// proxyClientTimeout 与 SDK 默认 HTTPClient 超时保持一致。
|
// proxyClientTimeout 与 SDK 默认 HTTPClient 超时保持一致。
|
||||||
const proxyClientTimeout = 60 * time.Second
|
const proxyClientTimeout = 60 * time.Second
|
||||||
|
|
||||||
|
// 阶段超时对齐 SDK 直连 Transport 模板(transport_template_provider):连不上的
|
||||||
|
// 代理快速失败,而不是拖满总超时;responses 直通去掉总超时后这是建立阶段的兜底之一。
|
||||||
|
const (
|
||||||
|
proxyDialTimeout = 30 * time.Second
|
||||||
|
proxyTLSHandshakeTimeout = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
// applyProxy 在 SDK client 构造后统一挂出站代理;未关联代理时不动默认配置。
|
// applyProxy 在 SDK client 构造后统一挂出站代理;未关联代理时不动默认配置。
|
||||||
// 所有 New*ClientWithConfigurationProvider 调用点构造成功后都必须经过这里。
|
// 所有 New*ClientWithConfigurationProvider 调用点构造成功后都必须经过这里。
|
||||||
func applyProxy(base *common.BaseClient, cred Credentials) {
|
func applyProxy(base *common.BaseClient, cred Credentials) {
|
||||||
@@ -52,18 +60,23 @@ func HTTPClientFor(p *ProxySpec) *http.Client {
|
|||||||
// transportFor 构造代理 Transport:http / https 走 CONNECT,socks5 走拨号器。
|
// transportFor 构造代理 Transport:http / https 走 CONNECT,socks5 走拨号器。
|
||||||
func transportFor(p *ProxySpec) *http.Transport {
|
func transportFor(p *ProxySpec) *http.Transport {
|
||||||
addr := fmt.Sprintf("%s:%d", p.Host, p.Port)
|
addr := fmt.Sprintf("%s:%d", p.Host, p.Port)
|
||||||
|
dialer := &net.Dialer{Timeout: proxyDialTimeout}
|
||||||
if p.Type == "http" || p.Type == "https" {
|
if p.Type == "http" || p.Type == "https" {
|
||||||
u := &url.URL{Scheme: p.Type, Host: addr}
|
u := &url.URL{Scheme: p.Type, Host: addr}
|
||||||
if p.Username != "" {
|
if p.Username != "" {
|
||||||
u.User = url.UserPassword(p.Username, p.Password)
|
u.User = url.UserPassword(p.Username, p.Password)
|
||||||
}
|
}
|
||||||
return &http.Transport{Proxy: http.ProxyURL(u)}
|
return &http.Transport{
|
||||||
|
Proxy: http.ProxyURL(u),
|
||||||
|
DialContext: dialer.DialContext,
|
||||||
|
TLSHandshakeTimeout: proxyTLSHandshakeTimeout,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var auth *proxy.Auth
|
var auth *proxy.Auth
|
||||||
if p.Username != "" {
|
if p.Username != "" {
|
||||||
auth = &proxy.Auth{User: p.Username, Password: p.Password}
|
auth = &proxy.Auth{User: p.Username, Password: p.Password}
|
||||||
}
|
}
|
||||||
d, err := proxy.SOCKS5("tcp", addr, auth, proxy.Direct)
|
d, err := proxy.SOCKS5("tcp", addr, auth, dialer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -71,5 +84,8 @@ func transportFor(p *ProxySpec) *http.Transport {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &http.Transport{DialContext: cd.DialContext}
|
return &http.Transport{
|
||||||
|
DialContext: cd.DialContext,
|
||||||
|
TLSHandshakeTimeout: proxyTLSHandshakeTimeout,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTransportForStageTimeouts(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
spec *ProxySpec
|
||||||
|
wantProxy bool // CONNECT 分支应设 Proxy 函数
|
||||||
|
}{
|
||||||
|
{name: "http CONNECT 代理", spec: &ProxySpec{Type: "http", Host: "127.0.0.1", Port: 8080}, wantProxy: true},
|
||||||
|
{name: "https CONNECT 代理", spec: &ProxySpec{Type: "https", Host: "127.0.0.1", Port: 8443}, wantProxy: true},
|
||||||
|
{name: "socks5 代理", spec: &ProxySpec{Type: "socks5", Host: "127.0.0.1", Port: 1080, Username: "u", Password: "p"}},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
tr := transportFor(tt.spec)
|
||||||
|
if tr == nil {
|
||||||
|
t.Fatal("transportFor 返回 nil")
|
||||||
|
}
|
||||||
|
if (tr.Proxy != nil) != tt.wantProxy {
|
||||||
|
t.Fatalf("Proxy 函数存在性 = %v, 期望 %v", tr.Proxy != nil, tt.wantProxy)
|
||||||
|
}
|
||||||
|
if tr.DialContext == nil {
|
||||||
|
t.Fatal("应设置带超时的 DialContext")
|
||||||
|
}
|
||||||
|
if tr.TLSHandshakeTimeout != proxyTLSHandshakeTimeout {
|
||||||
|
t.Fatalf("TLSHandshakeTimeout = %v, 期望 %v", tr.TLSHandshakeTimeout, proxyTLSHandshakeTimeout)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+164
-12
@@ -10,6 +10,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -61,20 +62,82 @@ type AiGatewayService struct {
|
|||||||
onChannelsChanged func(context.Context)
|
onChannelsChanged func(context.Context)
|
||||||
// filterDeprecated 是「过滤弃用模型」开关(内存镜像,持久化在 settings 表)
|
// filterDeprecated 是「过滤弃用模型」开关(内存镜像,持久化在 settings 表)
|
||||||
filterDeprecated atomic.Bool
|
filterDeprecated atomic.Bool
|
||||||
|
// streamGuard* 是 Responses 流式保险丝(instructions+tools 合计超阈值改非流式)
|
||||||
|
streamGuardEnabled atomic.Bool
|
||||||
|
streamGuardKB atomic.Int64
|
||||||
|
// grokWebSearch / grokXSearch 是 xai. 模型服务端搜索工具默认注入开关
|
||||||
|
grokWebSearch atomic.Bool
|
||||||
|
grokXSearch atomic.Bool
|
||||||
|
// upstreamWaitSec 是 responses 直通的上游无响应预算(秒):非流式为单次尝试
|
||||||
|
// 总超时,流式为等待响应头预算;multi-agent/搜索类模型远超 SDK 默认 60s
|
||||||
|
upstreamWaitSec atomic.Int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAiGatewayService 组装依赖;调用 StartCleanup 后开始调用日志周期清理。
|
// NewAiGatewayService 组装依赖;调用 StartCleanup 后开始调用日志周期清理。
|
||||||
func NewAiGatewayService(db *gorm.DB, configs *OciConfigService, client oci.Client) *AiGatewayService {
|
func NewAiGatewayService(db *gorm.DB, configs *OciConfigService, client oci.Client) *AiGatewayService {
|
||||||
s := &AiGatewayService{db: db, configs: configs, client: client, lastTouch: map[uint]time.Time{}}
|
s := &AiGatewayService{db: db, configs: configs, client: client, lastTouch: map[uint]time.Time{}}
|
||||||
var row model.Setting
|
s.filterDeprecated.Store(loadBoolSetting(db, settingAiFilterDeprecated, false))
|
||||||
if err := db.Where("key = ?", settingAiFilterDeprecated).First(&row).Error; err == nil {
|
s.streamGuardEnabled.Store(loadBoolSetting(db, settingAiStreamGuardEnabled, true))
|
||||||
s.filterDeprecated.Store(row.Value == "1")
|
s.streamGuardKB.Store(int64(loadIntSetting(db, settingAiStreamGuardKB, defaultStreamGuardKB)))
|
||||||
}
|
s.grokWebSearch.Store(loadBoolSetting(db, settingAiGrokWebSearch, true))
|
||||||
|
s.grokXSearch.Store(loadBoolSetting(db, settingAiGrokXSearch, true))
|
||||||
|
s.upstreamWaitSec.Store(int64(loadIntSetting(db, settingAiUpstreamWaitSec, defaultUpstreamWaitSec)))
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// settingAiFilterDeprecated 是「过滤弃用模型」开关的配置键,值 "1"/"0",缺省关闭。
|
// AI 网关运行时设置的配置键;bool 值存 "1"/"0"。
|
||||||
const settingAiFilterDeprecated = "ai_filter_deprecated"
|
const (
|
||||||
|
// settingAiFilterDeprecated 是「过滤弃用模型」开关,缺省关闭。
|
||||||
|
settingAiFilterDeprecated = "ai_filter_deprecated"
|
||||||
|
// settingAiStreamGuardEnabled / settingAiStreamGuardKB 是流式保险丝开关与
|
||||||
|
// 阈值(KB),缺省开、60(上游对 instructions+tools >≈64.5KB 流式静默断流)。
|
||||||
|
settingAiStreamGuardEnabled = "ai_stream_guard_enabled"
|
||||||
|
settingAiStreamGuardKB = "ai_stream_guard_kb"
|
||||||
|
// settingAiGrokWebSearch / settingAiGrokXSearch 是 grok 搜索工具默认注入
|
||||||
|
// 开关,缺省开。
|
||||||
|
settingAiGrokWebSearch = "ai_grok_web_search"
|
||||||
|
settingAiGrokXSearch = "ai_grok_x_search"
|
||||||
|
// settingAiUpstreamWaitSec 是 responses 直通的上游无响应预算(秒)。
|
||||||
|
settingAiUpstreamWaitSec = "ai_upstream_wait_seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultStreamGuardKB 是保险丝阈值缺省值,低于实测断流边界留余量。
|
||||||
|
const defaultStreamGuardKB = 60
|
||||||
|
|
||||||
|
// defaultUpstreamWaitSec 是上游无响应预算缺省值(秒):multi-agent 非流式
|
||||||
|
// 实测 100~180s 才回响应头,给足余量;上下限见 SetUpstreamWait。
|
||||||
|
const defaultUpstreamWaitSec = 300
|
||||||
|
|
||||||
|
// loadBoolSetting 读 settings 表布尔键,无行或值非法时返回缺省。
|
||||||
|
func loadBoolSetting(db *gorm.DB, key string, def bool) bool {
|
||||||
|
var row model.Setting
|
||||||
|
if err := db.Where("key = ?", key).First(&row).Error; err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return row.Value == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadIntSetting 读 settings 表整数键,无行或解析失败时返回缺省。
|
||||||
|
func loadIntSetting(db *gorm.DB, key string, def int) int {
|
||||||
|
var row model.Setting
|
||||||
|
if err := db.Where("key = ?", key).First(&row).Error; err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(row.Value)
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveBoolSetting 持久化布尔键。
|
||||||
|
func (s *AiGatewayService) saveBoolSetting(ctx context.Context, key string, on bool) error {
|
||||||
|
value := "0"
|
||||||
|
if on {
|
||||||
|
value = "1"
|
||||||
|
}
|
||||||
|
return s.db.WithContext(ctx).Save(&model.Setting{Key: key, Value: value}).Error
|
||||||
|
}
|
||||||
|
|
||||||
// FilterDeprecated 返回「过滤弃用模型」开关状态。
|
// FilterDeprecated 返回「过滤弃用模型」开关状态。
|
||||||
func (s *AiGatewayService) FilterDeprecated() bool { return s.filterDeprecated.Load() }
|
func (s *AiGatewayService) FilterDeprecated() bool { return s.filterDeprecated.Load() }
|
||||||
@@ -82,18 +145,73 @@ func (s *AiGatewayService) FilterDeprecated() bool { return s.filterDeprecated.L
|
|||||||
// SetFilterDeprecated 持久化并即时生效开关:开启后已宣布弃用
|
// SetFilterDeprecated 持久化并即时生效开关:开启后已宣布弃用
|
||||||
// (deprecated_at 非空,即使未退役)的模型从列表与路由中排除。
|
// (deprecated_at 非空,即使未退役)的模型从列表与路由中排除。
|
||||||
func (s *AiGatewayService) SetFilterDeprecated(ctx context.Context, on bool) error {
|
func (s *AiGatewayService) SetFilterDeprecated(ctx context.Context, on bool) error {
|
||||||
value := "0"
|
if err := s.saveBoolSetting(ctx, settingAiFilterDeprecated, on); err != nil {
|
||||||
if on {
|
|
||||||
value = "1"
|
|
||||||
}
|
|
||||||
err := s.db.WithContext(ctx).Save(&model.Setting{Key: settingAiFilterDeprecated, Value: value}).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("保存过滤弃用模型开关: %w", err)
|
return fmt.Errorf("保存过滤弃用模型开关: %w", err)
|
||||||
}
|
}
|
||||||
s.filterDeprecated.Store(on)
|
s.filterDeprecated.Store(on)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StreamGuard 返回流式保险丝开关与阈值(KB)。
|
||||||
|
func (s *AiGatewayService) StreamGuard() (bool, int) {
|
||||||
|
return s.streamGuardEnabled.Load(), int(s.streamGuardKB.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStreamGuard 持久化并即时生效流式保险丝;kb 限定 1..1024。
|
||||||
|
func (s *AiGatewayService) SetStreamGuard(ctx context.Context, on bool, kb int) error {
|
||||||
|
if kb < 1 || kb > 1024 {
|
||||||
|
return fmt.Errorf("流式保险丝阈值须在 1..1024 KB, 收到 %d", kb)
|
||||||
|
}
|
||||||
|
if err := s.saveBoolSetting(ctx, settingAiStreamGuardEnabled, on); err != nil {
|
||||||
|
return fmt.Errorf("保存流式保险丝开关: %w", err)
|
||||||
|
}
|
||||||
|
err := s.db.WithContext(ctx).
|
||||||
|
Save(&model.Setting{Key: settingAiStreamGuardKB, Value: strconv.Itoa(kb)}).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("保存流式保险丝阈值: %w", err)
|
||||||
|
}
|
||||||
|
s.streamGuardEnabled.Store(on)
|
||||||
|
s.streamGuardKB.Store(int64(kb))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpstreamWait 返回 responses 直通的上游无响应预算。
|
||||||
|
func (s *AiGatewayService) UpstreamWait() time.Duration {
|
||||||
|
return time.Duration(s.upstreamWaitSec.Load()) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUpstreamWait 持久化并即时生效上游无响应预算;sec 限定 30..900。
|
||||||
|
func (s *AiGatewayService) SetUpstreamWait(ctx context.Context, sec int) error {
|
||||||
|
if sec < 30 || sec > 900 {
|
||||||
|
return fmt.Errorf("上游无响应预算须在 30..900 秒, 收到 %d", sec)
|
||||||
|
}
|
||||||
|
err := s.db.WithContext(ctx).
|
||||||
|
Save(&model.Setting{Key: settingAiUpstreamWaitSec, Value: strconv.Itoa(sec)}).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("保存上游无响应预算: %w", err)
|
||||||
|
}
|
||||||
|
s.upstreamWaitSec.Store(int64(sec))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrokSearch 返回 grok 服务端搜索工具默认注入开关(web_search, x_search)。
|
||||||
|
func (s *AiGatewayService) GrokSearch() (bool, bool) {
|
||||||
|
return s.grokWebSearch.Load(), s.grokXSearch.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetGrokSearch 持久化并即时生效 grok 搜索工具默认注入开关。
|
||||||
|
func (s *AiGatewayService) SetGrokSearch(ctx context.Context, web, x bool) error {
|
||||||
|
if err := s.saveBoolSetting(ctx, settingAiGrokWebSearch, web); err != nil {
|
||||||
|
return fmt.Errorf("保存 grok web_search 开关: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.saveBoolSetting(ctx, settingAiGrokXSearch, x); err != nil {
|
||||||
|
return fmt.Errorf("保存 grok x_search 开关: %w", err)
|
||||||
|
}
|
||||||
|
s.grokWebSearch.Store(web)
|
||||||
|
s.grokXSearch.Store(x)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// SetOnChannelsChanged 注册渠道数量变化钩子(渠道创建/删除成功后调用)。
|
// SetOnChannelsChanged 注册渠道数量变化钩子(渠道创建/删除成功后调用)。
|
||||||
func (s *AiGatewayService) SetOnChannelsChanged(fn func(context.Context)) {
|
func (s *AiGatewayService) SetOnChannelsChanged(fn func(context.Context)) {
|
||||||
s.onChannelsChanged = fn
|
s.onChannelsChanged = fn
|
||||||
@@ -661,6 +779,40 @@ func (s *AiGatewayService) GatewayModels(ctx context.Context, group string) (aiw
|
|||||||
return list, nil
|
return list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AggregatedModel 是聚合模型目录条目(设置页黑名单添加弹窗用)。
|
||||||
|
type AggregatedModel struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Capability string `json:"capability"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AggregatedModels 返回启用渠道去重后的模型目录(含能力);空能力归一为 CHAT。
|
||||||
|
// 与模型列表口径一致:「过滤弃用」开启时弃用模型不出现在目录中。
|
||||||
|
func (s *AiGatewayService) AggregatedModels(ctx context.Context) ([]AggregatedModel, error) {
|
||||||
|
q := s.db.WithContext(ctx).
|
||||||
|
Joins("JOIN ai_channels ON ai_channels.id = ai_model_caches.channel_id AND ai_channels.enabled = ?", true)
|
||||||
|
if s.FilterDeprecated() {
|
||||||
|
q = q.Where("ai_model_caches.deprecated_at IS NULL")
|
||||||
|
}
|
||||||
|
var rows []model.AiModelCache
|
||||||
|
if err := q.Order("ai_model_caches.name ASC").Find(&rows).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("聚合模型目录: %w", err)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := []AggregatedModel{}
|
||||||
|
for _, r := range rows {
|
||||||
|
if seen[r.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[r.Name] = true
|
||||||
|
cap := r.Capability
|
||||||
|
if cap == "" {
|
||||||
|
cap = "CHAT"
|
||||||
|
}
|
||||||
|
out = append(out, AggregatedModel{Name: r.Name, Capability: cap})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeprecatingModels 返回 within 窗口内即将退役或即将弃用的在池模型(按名称去重):
|
// DeprecatingModels 返回 within 窗口内即将退役或即将弃用的在池模型(按名称去重):
|
||||||
// 退役(TimeOnDemandRetired)才导致不可调用,单独标注;已过弃用日但未到退役日的
|
// 退役(TimeOnDemandRetired)才导致不可调用,单独标注;已过弃用日但未到退役日的
|
||||||
// 模型仍可正常调用,不再反复告警;已过退役日的在同步层剔除,不会出现在池中。
|
// 模型仍可正常调用,不再反复告警;已过退役日的在同步层剔除,不会出现在池中。
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ func (s *AiGatewayService) passthroughOnce(ctx context.Context, cand *aiCandidat
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.client.GenAiCompatResponses(ctx, cred, cand.ch.Region, raw)
|
return s.client.GenAiCompatResponses(ctx, cred, cand.ch.Region, raw, s.UpstreamWait())
|
||||||
}
|
}
|
||||||
|
|
||||||
// RespPassthroughStream 编排流式直通:流建立成功即绑定渠道,建立失败按 switchable
|
// RespPassthroughStream 编排流式直通:流建立成功即绑定渠道,建立失败按 switchable
|
||||||
@@ -83,7 +83,7 @@ func (s *AiGatewayService) RespPassthroughStream(ctx context.Context, raw []byte
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, meta, err
|
return nil, meta, err
|
||||||
}
|
}
|
||||||
stream, err := s.client.GenAiCompatResponsesStream(ctx, cred, cand.ch.Region, raw)
|
stream, err := s.client.GenAiCompatResponsesStream(ctx, cred, cand.ch.Region, raw, s.UpstreamWait())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
s.markSuccess(ctx, cand.ch.ID)
|
s.markSuccess(ctx, cand.ch.ID)
|
||||||
return stream, meta, nil
|
return stream, meta, nil
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ func (f *gatewayStubClient) GenAiApplyGuardrails(ctx context.Context, cred oci.C
|
|||||||
return f.guardOutcome, f.guardErr
|
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, wait time.Duration) ([]byte, error) {
|
||||||
f.passCalls++
|
f.passCalls++
|
||||||
f.passRegions = append(f.passRegions, region)
|
f.passRegions = append(f.passRegions, region)
|
||||||
if len(f.passErrs) > 0 {
|
if len(f.passErrs) > 0 {
|
||||||
@@ -96,7 +96,7 @@ func (f *gatewayStubClient) GenAiCompatResponses(ctx context.Context, cred oci.C
|
|||||||
return f.passPayload, nil
|
return f.passPayload, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *gatewayStubClient) GenAiCompatResponsesStream(ctx context.Context, cred oci.Credentials, region string, body []byte) (io.ReadCloser, error) {
|
func (f *gatewayStubClient) GenAiCompatResponsesStream(ctx context.Context, cred oci.Credentials, region string, body []byte, wait time.Duration) (io.ReadCloser, error) {
|
||||||
f.passCalls++
|
f.passCalls++
|
||||||
f.passRegions = append(f.passRegions, region)
|
f.passRegions = append(f.passRegions, region)
|
||||||
if len(f.passErrs) > 0 {
|
if len(f.passErrs) > 0 {
|
||||||
@@ -1060,3 +1060,102 @@ func TestRespPassthroughStreamSwitchesChannel(t *testing.T) {
|
|||||||
t.Errorf("流内容未透传: %s", payload)
|
t.Errorf("流内容未透传: %s", payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAiRuntimeSettings 断言流式保险丝与 grok 注入开关的缺省值、往返与持久化。
|
||||||
|
func TestAiRuntimeSettings(t *testing.T) {
|
||||||
|
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if on, kb := gw.StreamGuard(); !on || kb != 60 {
|
||||||
|
t.Fatalf("保险丝缺省应为 开/60, got %v/%d", on, kb)
|
||||||
|
}
|
||||||
|
if web, x := gw.GrokSearch(); !web || !x {
|
||||||
|
t.Fatalf("grok 注入缺省应双开, got %v/%v", web, x)
|
||||||
|
}
|
||||||
|
for _, bad := range []int{0, -1, 1025} {
|
||||||
|
if err := gw.SetStreamGuard(ctx, true, bad); err == nil {
|
||||||
|
t.Errorf("阈值 %d 应报错", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := gw.SetStreamGuard(ctx, false, 80); err != nil {
|
||||||
|
t.Fatalf("SetStreamGuard: %v", err)
|
||||||
|
}
|
||||||
|
if err := gw.SetGrokSearch(ctx, false, true); err != nil {
|
||||||
|
t.Fatalf("SetGrokSearch: %v", err)
|
||||||
|
}
|
||||||
|
gw2 := NewAiGatewayService(gw.db, svc, &gatewayStubClient{})
|
||||||
|
if on, kb := gw2.StreamGuard(); on || kb != 80 {
|
||||||
|
t.Errorf("重建后保险丝应为 关/80, got %v/%d", on, kb)
|
||||||
|
}
|
||||||
|
if web, x := gw2.GrokSearch(); web || !x {
|
||||||
|
t.Errorf("重建后 grok 注入应为 关/开, got %v/%v", web, x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAggregatedModelsFilterDeprecated 断言聚合目录与模型列表口径一致:
|
||||||
|
// 「过滤弃用」开启时弃用模型不出现,关闭时出现;空能力归一为 CHAT。
|
||||||
|
func TestAggregatedModelsFilterDeprecated(t *testing.T) {
|
||||||
|
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
|
||||||
|
dep := time.Now().Add(-24 * time.Hour)
|
||||||
|
old := &model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..dep", Name: "meta.llama-old",
|
||||||
|
Vendor: "meta", SyncedAt: time.Now(), DeprecatedAt: &dep}
|
||||||
|
if err := gw.db.Create(old).Error; err != nil {
|
||||||
|
t.Fatalf("seed deprecated cache: %v", err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
items, err := gw.AggregatedModels(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AggregatedModels: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 2 || items[0].Capability == "" {
|
||||||
|
t.Fatalf("开关关:应含弃用模型且能力归一, got %+v", items)
|
||||||
|
}
|
||||||
|
if err := gw.SetFilterDeprecated(ctx, true); err != nil {
|
||||||
|
t.Fatalf("SetFilterDeprecated: %v", err)
|
||||||
|
}
|
||||||
|
items, err = gw.AggregatedModels(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AggregatedModels(过滤): %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 || items[0].Name == "meta.llama-old" {
|
||||||
|
t.Fatalf("开关开:弃用模型应被过滤, got %+v", items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpstreamWaitSetting(t *testing.T) {
|
||||||
|
gw, _ := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{}})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if got := gw.UpstreamWait(); got != 300*time.Second {
|
||||||
|
t.Fatalf("缺省上游无响应预算 = %v, 期望 300s", got)
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sec int
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "下界 30 有效", sec: 30},
|
||||||
|
{name: "上界 900 有效", sec: 900},
|
||||||
|
{name: "低于下界拒绝", sec: 29, wantErr: true},
|
||||||
|
{name: "高于上界拒绝", sec: 901, wantErr: true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := gw.SetUpstreamWait(ctx, tt.sec)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("SetUpstreamWait(%d) = %v, wantErr=%v", tt.sec, err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if !tt.wantErr && gw.UpstreamWait() != time.Duration(tt.sec)*time.Second {
|
||||||
|
t.Fatalf("UpstreamWait = %v, 期望 %ds", gw.UpstreamWait(), tt.sec)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 持久化后新实例应加载已存值(最后一次成功设置为 900)
|
||||||
|
gw2 := NewAiGatewayService(gw.db, nil, nil)
|
||||||
|
if got := gw2.UpstreamWait(); got != 900*time.Second {
|
||||||
|
t.Fatalf("重建服务加载预算 = %v, 期望 900s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -320,10 +320,63 @@ func respToolDrop(tool map[string]any) (string, bool) {
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// RespStreamUpgradeLimit 是流式直通的请求体安全上限。实测(2026-07,xai 面):
|
// RespGuardBytes 返回请求体中 instructions 与 tools 两字段的原始字节数之和,
|
||||||
// 超过 ~82KB 的流式请求上游会在推理阶段掐断流(非流式不受影响,纯体积触发,
|
// 流式保险丝据此判定。上游对二者合计 >≈64.5KB 的流式请求会在推理阶段静默断流
|
||||||
// 与工具构成无关),预留余量取 76KB;超限时网关改走非流式上游并合成 SSE。
|
// (纯 EOF,input 正文不计入;2026-07-16 实测仍存在),阈值由设置页 AI Tab 配置。
|
||||||
const RespStreamUpgradeLimit = 76 * 1024
|
// 解析失败返回 0(放行,交由上游正常报错)。
|
||||||
|
func RespGuardBytes(body []byte) int {
|
||||||
|
var probe struct {
|
||||||
|
Instructions json.RawMessage `json:"instructions"`
|
||||||
|
Tools json.RawMessage `json:"tools"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &probe); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(probe.Instructions) + len(probe.Tools)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RespInjectGrokTools 为 xai. 前缀模型默认注入服务端搜索工具:开关开启且请求
|
||||||
|
// tools 中不存在同名工具时追加 {"type":"web_search"} / {"type":"x_search"};
|
||||||
|
// 已存在(含任意参数形态)不覆盖。返回改写后 body 与注入清单(观测日志用);
|
||||||
|
// 模型不匹配、两开关全关或解析失败时原样返回。
|
||||||
|
func RespInjectGrokTools(body []byte, model string, web, x bool) ([]byte, []string) {
|
||||||
|
if !strings.HasPrefix(model, "xai.") || (!web && !x) {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(strings.NewReader(string(body)))
|
||||||
|
dec.UseNumber()
|
||||||
|
var m map[string]any
|
||||||
|
if err := dec.Decode(&m); err != nil {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
tools, _ := m["tools"].([]any)
|
||||||
|
missing := map[string]bool{"web_search": web, "x_search": x}
|
||||||
|
for _, t := range tools {
|
||||||
|
tool, ok := t.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if typ, _ := tool["type"].(string); missing[typ] {
|
||||||
|
missing[typ] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var injected []string
|
||||||
|
for _, typ := range []string{"web_search", "x_search"} {
|
||||||
|
if missing[typ] {
|
||||||
|
tools = append(tools, map[string]any{"type": typ})
|
||||||
|
injected = append(injected, typ)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(injected) == 0 {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
m["tools"] = tools
|
||||||
|
out, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
return out, injected
|
||||||
|
}
|
||||||
|
|
||||||
// RespDisableStream 把请求体的 stream 改为 false(流式升级回退用),其余字段
|
// RespDisableStream 把请求体的 stream 改为 false(流式升级回退用),其余字段
|
||||||
// 原样保留。
|
// 原样保留。
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -329,6 +330,71 @@ func TestRespStreamCompletedUsage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRespGuardBytes 断言保险丝计量:instructions+tools 原始字节和,缺字段计 0,
|
||||||
|
// 解析失败返回 0 放行。
|
||||||
|
func TestRespGuardBytes(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, body string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"双字段", `{"instructions":"abcd","tools":[{"type":"web_search"}],"input":"xxxxxxxx"}`,
|
||||||
|
len(`"abcd"`) + len(`[{"type":"web_search"}]`)},
|
||||||
|
{"仅 instructions", `{"instructions":"abcd"}`, len(`"abcd"`)},
|
||||||
|
{"均缺失 input 不计", `{"input":"xxxxxxxxxxxxxxxx"}`, 0},
|
||||||
|
{"解析失败放行", `not-json`, 0},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := RespGuardBytes([]byte(tc.body)); got != tc.want {
|
||||||
|
t.Fatalf("RespGuardBytes() = %d, want %d", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRespInjectGrokTools 断言 grok 搜索工具默认注入:仅 xai. 模型、开关可控、
|
||||||
|
// 已带同名工具不覆盖、注入清单正确。
|
||||||
|
func TestRespInjectGrokTools(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, body, model string
|
||||||
|
web, x bool
|
||||||
|
wantInjected []string
|
||||||
|
wantContains []string
|
||||||
|
}{
|
||||||
|
{"非 xai 不注入", `{"tools":[]}`, "meta.llama-3.3", true, true, nil, nil},
|
||||||
|
{"双开无 tools 字段注入两个", `{"model":"xai.grok-4.3"}`, "xai.grok-4.3", true, true,
|
||||||
|
[]string{"web_search", "x_search"}, []string{`"web_search"`, `"x_search"`}},
|
||||||
|
{"已带 web_search 只注入 x_search", `{"tools":[{"type":"web_search","filters":{"x":1}}]}`,
|
||||||
|
"xai.grok-4.3", true, true, []string{"x_search"}, []string{`"filters"`}},
|
||||||
|
{"开关全关不注入", `{"tools":[]}`, "xai.grok-4.3", false, false, nil, nil},
|
||||||
|
{"仅开 x_search", `{"tools":[]}`, "xai.grok-4.3", false, true, []string{"x_search"}, nil},
|
||||||
|
{"解析失败原样", `not-json`, "xai.grok-4.3", true, true, nil, nil},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
out, injected := RespInjectGrokTools([]byte(tc.body), tc.model, tc.web, tc.x)
|
||||||
|
if fmt.Sprint(injected) != fmt.Sprint(tc.wantInjected) {
|
||||||
|
t.Fatalf("injected = %v, want %v", injected, tc.wantInjected)
|
||||||
|
}
|
||||||
|
if len(injected) == 0 && string(out) != tc.body {
|
||||||
|
t.Fatalf("未注入时应原样返回: %s", out)
|
||||||
|
}
|
||||||
|
for _, sub := range append(tc.wantContains, toolTypes(injected)...) {
|
||||||
|
if !strings.Contains(string(out), sub) {
|
||||||
|
t.Fatalf("输出缺少 %s: %s", sub, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toolTypes 把注入清单转为输出应包含的片段断言。
|
||||||
|
func toolTypes(injected []string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, typ := range injected {
|
||||||
|
out = append(out, `{"type":"`+typ+`"}`)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// TestRespDisableStream 断言流式升级回退把 stream 置 false 且其余字段保留。
|
// TestRespDisableStream 断言流式升级回退把 stream 置 false 且其余字段保留。
|
||||||
func TestRespDisableStream(t *testing.T) {
|
func TestRespDisableStream(t *testing.T) {
|
||||||
out, err := RespDisableStream([]byte(`{"model":"m","stream":true,"max_output_tokens":64}`))
|
out, err := RespDisableStream([]byte(`{"model":"m","stream":true,"max_output_tokens":64}`))
|
||||||
|
|||||||
@@ -30,19 +30,23 @@ var ErrInvalidAuditCursor = errors.New("audit events: invalid cursor, refresh to
|
|||||||
var ErrAuditEventGone = errors.New("原始事件已不可取回,请刷新列表后重试")
|
var ErrAuditEventGone = errors.New("原始事件已不可取回,请刷新列表后重试")
|
||||||
|
|
||||||
// AuditQuery 是批式懒加载查询参数:Cursor 为空表示自当前时刻首查,
|
// AuditQuery 是批式懒加载查询参数:Cursor 为空表示自当前时刻首查,
|
||||||
// 非空则从上次响应的游标位置继续向更早回溯;Limit 为单批目标条数。
|
// 非空则从上次响应的游标位置继续向更早回溯;Limit 为单批目标条数;
|
||||||
|
// Q 为检索关键字,仅首查生效(续查沿用游标内嵌的关键字,保证跨批一致)。
|
||||||
type AuditQuery struct {
|
type AuditQuery struct {
|
||||||
Region string
|
Region string
|
||||||
Cursor string
|
Cursor string
|
||||||
Limit int
|
Limit int
|
||||||
|
Q string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuditEventsView 是批式查询响应:列表不含 raw(详情接口取回);
|
// AuditEventsView 是批式查询响应:列表不含 raw(详情接口取回);
|
||||||
// Cursor 供下一批续查原样带回,空且 Exhausted 表示已到 365 天保留期尽头。
|
// Cursor 供下一批续查原样带回,空且 Exhausted 表示已到 365 天保留期尽头;
|
||||||
|
// ScannedThrough 为已完整回溯到的时刻(比它更新的时段已扫完),供前端展示进度。
|
||||||
type AuditEventsView struct {
|
type AuditEventsView struct {
|
||||||
Items []oci.AuditEvent `json:"items"`
|
Items []oci.AuditEvent `json:"items"`
|
||||||
Cursor string `json:"cursor,omitempty"`
|
Cursor string `json:"cursor,omitempty"`
|
||||||
Exhausted bool `json:"exhausted"`
|
Exhausted bool `json:"exhausted"`
|
||||||
|
ScannedThrough *time.Time `json:"scannedThrough,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuditEvents 实时查询租户 OCI 审计事件,纯透传不入库;region 为空时用配置
|
// AuditEvents 实时查询租户 OCI 审计事件,纯透传不入库;region 为空时用配置
|
||||||
@@ -52,6 +56,9 @@ func (s *OciConfigService) AuditEvents(ctx context.Context, id uint, q AuditQuer
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return AuditEventsView{}, err
|
return AuditEventsView{}, err
|
||||||
}
|
}
|
||||||
|
if q.Cursor == "" {
|
||||||
|
cur.Q = oci.SanitizeAuditTerm(q.Q)
|
||||||
|
}
|
||||||
cred, err := s.credentialsByID(ctx, id)
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AuditEventsView{}, err
|
return AuditEventsView{}, err
|
||||||
@@ -63,6 +70,7 @@ func (s *OciConfigService) AuditEvents(ctx context.Context, id uint, q AuditQuer
|
|||||||
view := AuditEventsView{Items: s.stripAuditRaw(id, res.Items), Exhausted: res.Exhausted}
|
view := AuditEventsView{Items: s.stripAuditRaw(id, res.Items), Exhausted: res.Exhausted}
|
||||||
if res.Cursor != nil {
|
if res.Cursor != nil {
|
||||||
view.Cursor = encodeAuditCursor(*res.Cursor)
|
view.Cursor = encodeAuditCursor(*res.Cursor)
|
||||||
|
view.ScannedThrough = &res.Cursor.End
|
||||||
}
|
}
|
||||||
return view, nil
|
return view, nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user