Compare commits
24
Commits
v0.1.0
...
882eeade1e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
882eeade1e | ||
|
|
7019d4c5a6 | ||
|
|
18e63d2dbd | ||
|
|
91999205e2 | ||
|
|
cb66567256 | ||
|
|
d56678e1de | ||
|
|
8897c847a1 | ||
|
|
b2252678dd | ||
|
|
7002e42c06 | ||
|
|
da7b29d2e3 | ||
|
|
99b551401e | ||
|
|
4e2bab3032 | ||
|
|
e1f8a0539c | ||
|
|
a8bde89b56 | ||
|
|
1da2197a6c | ||
|
|
79c9e4d9b9 | ||
|
|
2fea315430 | ||
|
|
b4ef98a25e | ||
|
|
0a86b5a291 | ||
|
|
9309ad1ffc | ||
|
|
81d4650f3d | ||
|
|
c7cc5616ed | ||
|
|
489cb49cb3 | ||
|
|
7706f59549 |
@@ -23,7 +23,7 @@ jobs:
|
|||||||
echo "IMAGE=${REGISTRY}/${{ gitea.repository }}" >> "$GITHUB_ENV"
|
echo "IMAGE=${REGISTRY}/${{ gitea.repository }}" >> "$GITHUB_ENV"
|
||||||
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
|
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: 下载 DASH_VERSION 固定版本的前端 dist.zip,校验后解压进嵌入目录
|
- name: 下载前端 dist.zip
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.BUILD_TOKEN }}
|
TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
unzip -q dist.zip -d internal/webui/dist
|
unzip -q dist.zip -d internal/webui/dist
|
||||||
test -f internal/webui/dist/index.html
|
test -f internal/webui/dist/index.html
|
||||||
|
|
||||||
- name: 构建双架构二进制(artifact stage 导出)
|
- name: 构建双架构二进制
|
||||||
run: |
|
run: |
|
||||||
docker buildx build --pull \
|
docker buildx build --pull \
|
||||||
--platform linux/amd64,linux/arm64 \
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.6.5
|
0.6.6
|
||||||
@@ -24,7 +24,20 @@ Questions to answer:
|
|||||||
|
|
||||||
<!-- How should queries be written? Batch operations? -->
|
<!-- How should queries be written? Batch operations? -->
|
||||||
|
|
||||||
(To be filled by the team)
|
### 租户级数据删除
|
||||||
|
|
||||||
|
- 租户主体与本地关联数据必须在同一 GORM transaction 中按“子记录→父记录”删除,每步错误用 `%w` 返回,不得忽略。
|
||||||
|
- JSON payload/Setting 键等非外键引用要显式枚举并改写;不能只依赖 `AutoMigrate` 或 ORM association 推断级联范围。
|
||||||
|
- 与后台任务、Webhook、解析器等并发写入交叉时,先定义全局一致的行锁顺序,并在写入前重新确认父记录存在。
|
||||||
|
- 进程内 cron/缓存只在事务提交后同步;客户端取消不应中断已提交删除的必需运行时对齐。
|
||||||
|
- 批量删除/清理遇到**无法解析的 JSON payload** 时记 `log.Printf` 警告并跳过该行(原样保留),不 fail-closed 阻断整个流程——坏数据不应把删除逼到手工修库(tenantdelete.go `logSkippedTask`)。
|
||||||
|
|
||||||
|
### 大集合谓词用子查询,禁止展开 IN 列表
|
||||||
|
|
||||||
|
- 行数无上界的集合(日志事件、调用日志等)做关联删除/查询时,一律 `WHERE x IN (SELECT …)` 子查询,**不要**先 Pluck ID 再 `IN ?` 展开:绑定变量有硬上限(modernc SQLite 32766,MySQL/PG 65535),数万行即失败且重试无解。
|
||||||
|
- GORM 写法:把 `tx.Model(&T{}).Select("id").Where(...)` 作为参数传入 `Where("x IN (?)", sub)`(见 tenantdelete.go `deleteAlertHits` / `alertHitRuleIDs`)。
|
||||||
|
- 若原本的 Pluck 兼有 `FOR UPDATE` 锁定语义,保留锁定 SELECT 本身,只是不再把结果拼进后续 SQL(`lockTenantEventRows`)。
|
||||||
|
- 行数有小上界的集合(渠道、规则等配置类)可以继续用内存 ID 列表。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -48,6 +61,14 @@ Questions to answer:
|
|||||||
|
|
||||||
<!-- Database-related mistakes your team has made -->
|
<!-- Database-related mistakes your team has made -->
|
||||||
|
|
||||||
|
### Common Mistake: 用 `Save` 持久化在途任务的陈旧快照
|
||||||
|
|
||||||
|
**Symptom**:一条记录已被另一事务删除,在途任务随后执行 `Save` 却把该行重新插入,或覆盖并发更新后的 payload。
|
||||||
|
|
||||||
|
**Cause**:GORM `Save` 在 `UPDATE` 零命中时会回退到 `CREATE`/upsert,不适合持久化长时运行开始时读取的快照。
|
||||||
|
|
||||||
|
**Fix**:用 `WHERE id = ? AND updated_at = ?` 的条件 `Updates`,并严格要求 `RowsAffected == 1`;零命中表示记录已删除或版本已变,不得补做 `Create`。
|
||||||
|
|
||||||
### Common Mistake: gorm 读 NULL 列到已有值的结构体字段不会清零
|
### Common Mistake: gorm 读 NULL 列到已有值的结构体字段不会清零
|
||||||
|
|
||||||
**Symptom**:UPDATE 把可空列(如 `*time.Time`)写成 NULL 后,用**同一个结构体变量**再次 `First()` 读回,该字段仍是旧值;而新变量读取正常。断言/返回值出现「幽灵旧值」。
|
**Symptom**:UPDATE 把可空列(如 `*time.Time`)写成 NULL 后,用**同一个结构体变量**再次 `First()` 读回,该字段仍是旧值;而新变量读取正常。断言/返回值出现「幽灵旧值」。
|
||||||
@@ -66,3 +87,19 @@ db.Model(&model.AiChannel{}).
|
|||||||
Where("id = ? AND (fail_count > 0 OR disabled_until IS NOT NULL)", id).
|
Where("id = ? AND (fail_count > 0 OR disabled_until IS NOT NULL)", id).
|
||||||
Updates(map[string]any{"fail_count": 0, "disabled_until": gorm.Expr("NULL")})
|
Updates(map[string]any{"fail_count": 0, "disabled_until": gorm.Expr("NULL")})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `serializer:json` 字段走 map Updates 时手动 marshal
|
||||||
|
|
||||||
|
- 切片/结构体字段用 `gorm:"serializer:json;type:text"` 声明(如 `AiKey.Models []string`),Create/First/struct 路径自动序列化;
|
||||||
|
- 但 `Updates(map[string]any{...})` 路径不要依赖 GORM 对 map 值应用 serializer——把值 `json.Marshal` 成 string 放进 map(见 aigateway.go `UpdateKey`),行为版本无关且可测;
|
||||||
|
- 存储格式与 serializer 一致(JSON 文本),读回仍走自动反序列化;`nil` 切片 marshal 为 `null`,读回 nil,天然表达「空 = 不限」语义。
|
||||||
|
|
||||||
|
### Common Mistake: 进程内缓存键漏掉查询维度
|
||||||
|
|
||||||
|
**Symptom**:多区间(compartment)租户在前端切换区间后,实例/卷/VCN 列表短暂显示上一个区间的数据(TTL 窗口内)。
|
||||||
|
|
||||||
|
**Cause**:`internal/oci/cached.go` 的 `ckey` 只拼了租户 OCID+资源名+region,而底层查询按 `cred.EffectiveCompartment()` 过滤——影响结果的维度没有全部进键,不同参数命中同一条缓存。
|
||||||
|
|
||||||
|
**Fix**:键值加入 `cred.CompartmentID`(空 = 租户根,天然区分)。
|
||||||
|
|
||||||
|
**Prevention**:缓存键必须覆盖影响回源结果的**全部**输入维度(租户、区间、区域、过滤参数);给 Credentials/查询结构体新增会改变结果的字段时,同步检查 `ckey` 调用点;隔离行为写进 `cached_test.go` 的 isolation 用例。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Backend Development Guidelines(oci-portal 后端规范)
|
# Backend Development Guidelines(oci-portal 后端规范)
|
||||||
|
|
||||||
> Go 后端(`oci-portal/`)编码规范入口。规范提炼自 Google / Uber Go Style Guide、Effective Go 与 Go 官方模块布局指南,按本项目裁剪;条目与项目约定冲突时,以本 spec 为准。迁自 docs/开发指南.md 与根目录 AGENTS.md(2026-07)。
|
> Go 后端(`oci-portal/`)编码规范入口。规范提炼自 Google / Uber Go Style Guide、Effective Go 与 Go 官方模块布局指南,按本项目裁剪;条目与项目约定冲突时,以本 spec 为准。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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) | 结构化日志、日志级别 | 待填 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -51,7 +52,7 @@ go mod tidy # 依赖有变更时
|
|||||||
全部 HTTP 接口带 swaggo 注释(@Summary 中文/@Tags 按域/@Param/@Success/@Router;JWT 组接口标 @Security BearerAuth)。**新增或修改接口必须同步注释**,并重新生成 spec:
|
全部 HTTP 接口带 swaggo 注释(@Summary 中文/@Tags 按域/@Param/@Success/@Router;JWT 组接口标 @Security BearerAuth)。**新增或修改接口必须同步注释**,并重新生成 spec:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go tool swag init -g cmd/server/main.go -o docs --parseInternal --parseDependency
|
go tool swag init -g cmd/server/main.go -o docs --parseInternal --parseDependency --overridesFile docs/.swaggo
|
||||||
```
|
```
|
||||||
|
|
||||||
生成的 `docs/` 一并提交;UI 由 `SWAGGER=1` 开启(/swagger/index.html)。同名 handler 方法(list/create/get/update/remove)分布在多个 struct 上,写注释时确认 @Router 路径与该 receiver 的真实注册一致(routes_*.go)。
|
生成的 `docs/` 一并提交;UI 由 `SWAGGER=1` 开启(/swagger/index.html)。同名 handler 方法(list/create/get/update/remove)分布在多个 struct 上,写注释时确认 @Router 路径与该 receiver 的真实注册一致(routes_*.go)。
|
||||||
|
|||||||
@@ -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` 供前端展示回溯进度,前端自动补批必须封顶,由用户显式继续。
|
||||||
+13
-13
@@ -183,7 +183,7 @@ Complex task: ask the user if you can create a Trellis task and enter the planni
|
|||||||
- 1.0 Create task `[required · once]` (only after task-creation consent)
|
- 1.0 Create task `[required · once]` (only after task-creation consent)
|
||||||
- 1.1 Requirement exploration `[required · repeatable]` (`prd.md`; complex tasks also need `design.md` + `implement.md`)
|
- 1.1 Requirement exploration `[required · repeatable]` (`prd.md`; complex tasks also need `design.md` + `implement.md`)
|
||||||
- 1.2 Research `[optional · repeatable]`
|
- 1.2 Research `[optional · repeatable]`
|
||||||
- 1.3 Configure context `[required · once]` — Claude Code, Cursor, OpenCode, Codex, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix (sub-agent-dispatch platforms only; inline platforms skip)
|
- 1.3 Configure context `[required · once]` — Claude Code, Cursor, OpenCode, Codex, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix (sub-agent-dispatch platforms only; inline platforms skip)
|
||||||
- 1.4 Activate task `[required · once]` (review gate, then `task.py start`; status → in_progress)
|
- 1.4 Activate task `[required · once]` (review gate, then `task.py start`; status → in_progress)
|
||||||
- 1.5 Completion criteria
|
- 1.5 Completion criteria
|
||||||
|
|
||||||
@@ -272,13 +272,13 @@ Code committed. Run `/trellis:finish-work`; if dirty, return to Phase 3.4 first.
|
|||||||
|
|
||||||
When a user request matches one of these intents inside an active task, route first, then load the detailed phase step if needed.
|
When a user request matches one of these intents inside an active task, route first, then load the detailed phase step if needed.
|
||||||
|
|
||||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
|
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||||
|
|
||||||
- Planning or unclear requirements -> `trellis-brainstorm`.
|
- Planning or unclear requirements -> `trellis-brainstorm`.
|
||||||
- `in_progress` implementation/check -> dispatch `trellis-implement` / `trellis-check`.
|
- `in_progress` implementation/check -> dispatch `trellis-implement` / `trellis-check`.
|
||||||
- Repeated debugging -> `trellis-break-loop`; spec updates -> `trellis-update-spec`.
|
- Repeated debugging -> `trellis-break-loop`; spec updates -> `trellis-update-spec`.
|
||||||
|
|
||||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
|
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||||
|
|
||||||
[codex-inline, Kilo, Antigravity, Devin]
|
[codex-inline, Kilo, Antigravity, Devin]
|
||||||
|
|
||||||
@@ -353,7 +353,7 @@ Return to this step whenever requirements change and revise the relevant artifac
|
|||||||
|
|
||||||
Research can happen at any time during requirement exploration. It isn't limited to local code — you can use any available tool (MCP servers, skills, web search, etc.) to look up external information, including third-party library docs, industry practices, API references, etc.
|
Research can happen at any time during requirement exploration. It isn't limited to local code — you can use any available tool (MCP servers, skills, web search, etc.) to look up external information, including third-party library docs, industry practices, API references, etc.
|
||||||
|
|
||||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
|
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||||
|
|
||||||
Spawn the research sub-agent:
|
Spawn the research sub-agent:
|
||||||
|
|
||||||
@@ -361,7 +361,7 @@ Spawn the research sub-agent:
|
|||||||
- **Task description**: Research <specific question>
|
- **Task description**: Research <specific question>
|
||||||
- **Key requirement**: Research output MUST be persisted to `{TASK_DIR}/research/`
|
- **Key requirement**: Research output MUST be persisted to `{TASK_DIR}/research/`
|
||||||
|
|
||||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
|
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||||
|
|
||||||
[codex-inline, Kilo, Antigravity, Devin]
|
[codex-inline, Kilo, Antigravity, Devin]
|
||||||
|
|
||||||
@@ -380,7 +380,7 @@ Brainstorm and research can interleave freely — pause to research a technical
|
|||||||
|
|
||||||
#### 1.3 Configure context `[required · once]`
|
#### 1.3 Configure context `[required · once]`
|
||||||
|
|
||||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
|
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||||
|
|
||||||
Curate `implement.jsonl` and `check.jsonl` so the Phase 2 sub-agents get the right spec/research context. These files were seeded on `task create` with a single self-describing `_example` line; your job here is to fill in real entries.
|
Curate `implement.jsonl` and `check.jsonl` so the Phase 2 sub-agents get the right spec/research context. These files were seeded on `task create` with a single self-describing `_example` line; your job here is to fill in real entries.
|
||||||
|
|
||||||
@@ -425,7 +425,7 @@ Ready gate: both `implement.jsonl` and `check.jsonl` must contain at least one r
|
|||||||
|
|
||||||
Skip this step only when both files already have real curated entries.
|
Skip this step only when both files already have real curated entries.
|
||||||
|
|
||||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
|
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||||
|
|
||||||
[codex-inline, Kilo, Antigravity, Devin]
|
[codex-inline, Kilo, Antigravity, Devin]
|
||||||
|
|
||||||
@@ -458,11 +458,11 @@ If `task.py start` errors with a session-identity message (no context key from h
|
|||||||
| `design.md` exists (complex tasks) | ✅ |
|
| `design.md` exists (complex tasks) | ✅ |
|
||||||
| `implement.md` exists (complex tasks) | ✅ |
|
| `implement.md` exists (complex tasks) | ✅ |
|
||||||
|
|
||||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
|
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||||
|
|
||||||
| `implement.jsonl` and `check.jsonl` each contain at least one real curated entry (seed row does not count) | ✅ |
|
| `implement.jsonl` and `check.jsonl` each contain at least one real curated entry (seed row does not count) | ✅ |
|
||||||
|
|
||||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
|
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -472,7 +472,7 @@ Goal: turn reviewed planning artifacts into code that passes quality checks.
|
|||||||
|
|
||||||
#### 2.1 Implement `[required · repeatable]`
|
#### 2.1 Implement `[required · repeatable]`
|
||||||
|
|
||||||
[Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi]
|
[Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi, Oh My Pi]
|
||||||
|
|
||||||
Spawn the implement sub-agent:
|
Spawn the implement sub-agent:
|
||||||
|
|
||||||
@@ -484,7 +484,7 @@ The platform hook/plugin auto-handles:
|
|||||||
- Reads `implement.jsonl` and injects referenced spec/research files into the agent prompt
|
- Reads `implement.jsonl` and injects referenced spec/research files into the agent prompt
|
||||||
- Injects `prd.md`, `design.md` if present, and `implement.md` if present
|
- Injects `prd.md`, `design.md` if present, and `implement.md` if present
|
||||||
|
|
||||||
[/Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi]
|
[/Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi, Oh My Pi]
|
||||||
|
|
||||||
[codex-sub-agent, Gemini, Qoder, Copilot, ZCode, Reasonix, Trae]
|
[codex-sub-agent, Gemini, Qoder, Copilot, ZCode, Reasonix, Trae]
|
||||||
|
|
||||||
@@ -526,7 +526,7 @@ The platform prelude auto-handles the context load requirement:
|
|||||||
|
|
||||||
#### 2.2 Quality check `[required · repeatable]`
|
#### 2.2 Quality check `[required · repeatable]`
|
||||||
|
|
||||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
|
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||||
|
|
||||||
Spawn the check sub-agent:
|
Spawn the check sub-agent:
|
||||||
|
|
||||||
@@ -540,7 +540,7 @@ The check agent's job:
|
|||||||
- Auto-fix issues it finds
|
- Auto-fix issues it finds
|
||||||
- Run lint and typecheck to verify
|
- Run lint and typecheck to verify
|
||||||
|
|
||||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
|
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||||
|
|
||||||
[codex-inline, Kilo, Antigravity, Devin]
|
[codex-inline, Kilo, Antigravity, Devin]
|
||||||
|
|
||||||
|
|||||||
+170
-3
@@ -1,8 +1,175 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
|
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。
|
||||||
|
|
||||||
## [0.1.0] - 2026-07-10
|
## [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]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Responses 直通 codex 兼容层,codex CLI(`wire_api = "responses"`)指向网关即用,实测 codex-cli 0.144.1 主会话与 multi-agent 子代理全链路可用:
|
||||||
|
- `namespace` 工具组(承载 codex multi-agent 与全部 MCP 工具,仅 OpenAI 原生后端识别,OCI 实测 422)拍平为限定名 `function` 工具上送(`ns__child`,`mcp__` 开头子工具名不加前缀),响应中命中的 `function_call` 还原为短名并补回 `namespace` 字段(codex 以该双字段路由);多轮历史 `function_call` 与对象形态 `tool_choice` 上的 `namespace` 字段自动重限定
|
||||||
|
- `custom` 自由格式工具(apply_patch 及 GPT-5.x 档位子代理形态,OCI 不识别)转换为带 `{"input": string}` 包装 schema 的 `function`,响应回转 `custom_tool_call` 并解包 `input`,多轮历史的 `custom_tool_call(_output)` 逆向转换,往返无损;例外:`apply_patch` 整体剥离(grok 系未训练 codex 补丁格式,模型自然回落 shell 编辑)
|
||||||
|
- `tool_search`(codex 工具目录搜索)剥离:namespace 已全量拍平上送,搜索语义冗余且上游不识别
|
||||||
|
- `web_search` 的 OpenAI 专有参数 `external_web_access`(OCI 实测 400):`true` 仅删键放行(等价上游默认行为),`false` 为"仅缓存检索"降权模式,按不越权原则连工具剥离
|
||||||
|
- 以上改写动作(拍平 / 转换 / 剥离)与校验拒绝均记服务端日志可观测
|
||||||
|
- Responses 直通流式升级回退:实测 OCI 上游对超过约 82KB 的流式请求会在推理阶段掐断流(纯请求体积触发,与工具构成无关,非流式不受影响),请求体超 76KB 时自动改调非流式上游并合成最小 SSE 事件序列(`created` → 逐项 `output_item.done` → `completed`)返回,语义完整仅丢失增量输出——0.3.1 中「Responses 直通无法透明降级」的限制自此按体积预判解除
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `/ai/v1/responses` 工具类型白名单扩展:`namespace` / `custom` / `tool_search` 不再 400 拒绝,按上述兼容策略处理后转发;其余未知工具类型维持请求前置拒绝
|
||||||
|
|
||||||
|
## [0.6.0]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 渠道模型缓存列表端点 `GET /api/v1/ai-channels/{id}/models`:黑名单模型查询层兜底排除,「过滤弃用模型」开关开启时同样剔除已宣布弃用者(数据保留,展示口径过滤)
|
||||||
|
- 单模型测试端点 `POST /api/v1/ai-channels/{id}/test-model`:对指定模型发 max_tokens=16 试调,通过即写入渠道「探测验证模型」(此后手动探测与每日后台任务将其置于试调候选首位),渠道探测状态不为可用时顺带置可用并复位熔断;未通过如实返回上游错误且不改动渠道状态
|
||||||
|
- 渠道列表响应回填 `modelCount`(模型缓存计数,与模型列表同口径:排除黑名单、随过滤弃用开关)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 修复渠道探测「无配额」误判:配额试调遇 401/403/鉴权 404 不再立即定论租户无配额(可能仅个别模型无权限),改为继续尝试其余候选,任一成功即判可用,全部失败且出现过鉴权拒绝才判无配额
|
||||||
|
- 探测试调 `max_output_tokens` 由 1 提升到 16:openai.gpt-oss 系列要求 ≥16,原值被 400 拒导致仅有该系列对话模型的渠道被误判
|
||||||
|
|
||||||
|
## [0.5.1]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 修复外部身份(OAuth2)登录 / 绑定无系统日志的问题:回调成功记 200(用户名为面板账号)、失败记 401(附失败原因);此前回调为 GET 请求不经写操作日志中间件,完全无记录
|
||||||
|
|
||||||
|
## [0.5.0]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- xAI 官方格式文本转语音端点 `POST /ai/v1/tts`:接受 xAI 官方 TTS 请求(`text` / `language` 必填,`voice_id`、`output_format`{codec, sample_rate, bit_rate}、`speed` 等),网关转换为 OpenAI 兼容形态后与 `/ai/v1/audio/speech` 走同一上游与渠道调度;`model` 为网关扩展字段(缺省 `xai.grok-tts`);实测 `output_format` 对象与 `speed` 透传生效,xAI SDK / 客户端可直接指向网关
|
||||||
|
- 「过滤弃用模型」开关(`GET` / `PUT /api/v1/ai-settings`,持久化):开启后 OCI 已宣布弃用(即使未到退役日)的模型从模型列表与路由中同时排除,关闭恢复;渠道同步入库与 30 天退役提醒不受影响
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- AI 网关文档更名为 `docs/AI网关.md`(原 `docs/ai-gateway.md`),README 引用同步
|
||||||
|
|
||||||
|
## [0.4.0]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 文本转语音端点 `POST /ai/v1/audio/speech`:OpenAI Audio Speech 兼容,直通 OCI 兼容面(模型 `xai.grok-tts`,voice 取 xAI Grok Voice 列表 `ara`/`eve`/`leo`/`rex`/`sal`);上游必填的 `language` 缺省时自动注入 `"auto"`,xAI 专属参数平铺透传;响应为一次性完整音频(Content-Type 透传上游,缺省 audio/mpeg),不提供流式
|
||||||
|
- 文档重排端点 `POST /ai/v1/rerank`:Jina / Cohere 通行协议,走 OCI typed 面(`cohere.rerank-v4.0-pro` / `-fast`),支持 `top_n` 与 `return_documents`,`results[].index` 指向入参 `documents` 下标
|
||||||
|
- 内容审核端点 `POST /ai/v1/moderations`:OpenAI moderations 外壳映射 OCI Guardrails(内容审核 / PII / 提示注入),`input` 为字符串或字符串数组(单次至多 8 条),categories 用 OCI 原生维度 `overall` / `blocklist` / `prompt_injection`(任一得分 ≥0.5 判 `flagged`),PII 命中放扩展字段 `results[].pii`(不参与 flagged;实测中文人名 / 手机号识别较弱,英文正常)
|
||||||
|
- Responses 服务端工具扩展:放行 Oracle 文档化的 xAI `code_interpreter` 与远程 `mcp` 工具,并解除 `web_search` / `x_search` 仅非流式的限制——四类服务端工具非流式与流式均实测可用;`code_interpreter` 的命名容器管理与 File Search 不提供
|
||||||
|
- 模型能力映射扩展:`TEXT_RERANK` → RERANK、`TEXT_TO_AUDIO` → TTS,渠道探测 / 同步自动发现重排与语音模型入池
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- AI 网关文档独立成篇 `docs/ai-gateway.md`(端点定位、兼容边界、已知上游限制、字段兼容矩阵——矩阵只列支持项,不支持 / 忽略项以文字简述),README 精简为概览并引用;实测披露:Responses `input_file` 内容块被上游以 ZDR 形态拒绝,能力不可用
|
||||||
|
- swagger 全面修缺:135 处响应注解从泛型 map 改为具体类型(补文档用途响应结构与泛型列表外壳),AI 网关端点请求 / 响应 schema 完整可见;`json.RawMessage` 与联合类型统一渲染为任意 JSON 值(AnyJSON),不再误显示为 byte 数组;swag overrides 配置移至 `docs/.swaggo`,生成命令加 `--overridesFile docs/.swaggo`;顺带修正网页控制台会话创建响应码(200→201)与 SAML 元数据下载(改文件响应)两处注解失真
|
||||||
|
|
||||||
|
## [0.3.1]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- AI 网关流式断流自动降级:Messages 与 Chat Completions 流式请求在客户端尚未收到任何输出时遭遇上游断流,自动降级为非流式重做,结果按标准事件 / chunk 序列一次推送,调用日志记 `retries=1` 与降级标记。实测 OCI 兼容面对 `instructions` 与 `tools` 合计超约 64.5KB 的流式请求会静默断连(无错误事件,非流式正常,消息正文不计入),Claude Code 等大体量系统提示客户端极易触发;Responses 直通因初始事件已转发无法透明降级,日志记「上游流提前终止」;README 增补「已知上游限制:大 system 区流式断流」小节
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Messages 的 `max_tokens` 改为可缺省:缺省或 ≤0 时按默认值 8192 放行(此前返回 400;部分客户端将该字段视为选填)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- AI 网关流式假成功:上游 error / `response.failed` 事件此前被吞掉,流提前 EOF 也被伪装成正常结束,调用日志呈现 200 · 0/0 且无错误信息;现 Messages 将上游失败转为 Anthropic `error` 事件,三个流式端点日志均记录上游错误消息或「上游流提前终止,未返回终态事件」
|
||||||
|
|
||||||
|
## [0.3.0]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 恢复 OpenAI Chat Completions 兼容端点 `/ai/v1/chat/completions`:请求经 OCI Responses 接口转换转发,支持非流式与 SSE、文本与图片、function 工具调用、结构化输出、推理力度、模型白名单、缓存 token 用量和调用日志
|
||||||
|
- 代理侧批量关联租户接口 `PUT /api/v1/proxies/{id}/tenants`:一次调用设置关联该代理的租户全集——列表内租户改挂到本代理(含从其他代理改挂),列表外已关联的解除,事务生效并返回最新关联计数;含无效租户 ID 返回 400,代理不存在返回 404
|
||||||
|
- `/api/v1/about` 扩展运行时信息:进程启动时刻 `startedAt`、运行秒数 `uptimeSeconds` 与资源占用 `resources`——CPU 自启动均值(占单核百分比)、CPU 核数、goroutine 数、Go 堆内存与向系统申请内存、数据库引擎与磁盘占用(SQLite 统计主文件与 -wal / -shm;MySQL / PostgreSQL 外部库返回 -1 表示不可度量)
|
||||||
|
- README 重构部署、配置、鉴权、反向代理、AI 网关与升级发布说明,并新增 Responses、Chat Completions、Embeddings、Anthropic Messages 与标准接口的字段兼容矩阵,明确直通、转换、降级、忽略和拒绝边界
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 云端关键事件通知补充操作者、来源 IP、执行结果和说明信息;支持从 OCI Audit 与 IDCS 登录事件提取成功 / 失败状态、策略描述和登录失败原因,缺失字段统一显示占位符
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **移除自定义审计告警规则**及其阈值、窗口和冷却匹配能力;`/api/v1/log-events/alert-rules` 与 `/api/v1/log-events/alert-rules/{ruleId}` 管理接口、`audit_alert` 通知模板不再提供,已有规则升级后不再执行
|
||||||
|
|
||||||
|
## [0.2.0]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- AI 网关模型黑名单:面板集中拉黑模型,全渠道剔除(模型列表 / 路由 / 探测候选不再出现,同步不入库),移除条目并重新同步后恢复;新表 `ai_model_blacklists`(启动自动迁移),管理接口 `/api/v1/ai-blacklist`
|
||||||
|
- AI 密钥模型白名单:密钥可限定放行的模型列表(空 = 不限),白名单外调用 404,模型列表只返回交集
|
||||||
|
- 推理力度(effort)直通:Responses `reasoning.effort` 与 Anthropic Messages `output_config.effort` 原样透传上游、不做档位校验;grok-4.3 五档(`none` 完全关闭思考)、gpt-oss `minimal`-`high`、multi-agent `xhigh`(控制并行 agent 数)实测生效
|
||||||
|
- xAI Grok 服务端工具:`/ai/v1/responses` 的 `tools` 支持 `{"type":"web_search"}` / `{"type":"x_search"}`(官方 Agent Tools 格式,可与 `function` 混用,仅非流式),响应保留 `web_search_call` 输出项与引用
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **AI 网关对话链路整体切换到 OCI OpenAI 兼容面**(`/actions/v1/responses`),typed chat 面全链路移除:`/ai/v1/responses` 请求体原样直通(仅强制 `store:false`),流式为上游原生事件流,gpt-oss 推理增量直达客户端;`/ai/v1/messages` 转换为 Responses 请求直通,流式经事件桥转回标准 Anthropic 事件序列,`stop_sequences` / `top_k` 等无兼容面对应物的字段改为静默忽略,思考输出不转 `thinking` 块;渠道探测改用兼容面最小请求;流式建立后绑定渠道,中断不重试不计熔断
|
||||||
|
- 后台任务「立即执行」改异步触发:接口立即返回 202,执行结果经任务日志轮询呈现;同一任务在途时重复触发返回 409「任务正在执行中」,与 cron 重叠触发静默跳过(此前同步阻塞数十秒且同一任务可并发重复执行)
|
||||||
|
- 渠道探测候选跨厂商分散(上限 8,voice 等不可对话形态排除):单一厂商在区域内全为微调基座时不再拖垮整个渠道的探测结论
|
||||||
|
- 网关调用遇「模型不可按需调用」类错误(微调基座 400 / 实体不存在 404)自动换渠道重试且不计入渠道熔断,错误信息带模型名便于加入黑名单;仅鉴权类错误才判定租户无配额
|
||||||
|
- 同名模型多条目去重优先保留非微调基座条目,降低缓存到不可调用 OCID 的概率
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **`/ai/v1/chat/completions` 端点移除**(404):OpenAI 官方新能力已集中到 Responses 且本面板真实流量为零,请迁移 `/ai/v1/responses` 或 `/ai/v1/messages`
|
||||||
|
- google / cohere 对话模型不再供给(OCI OpenAI 兼容面不承接,上游 400;cohere embed 向量模型不受影响),模型目录按 `xai.` / `meta.` / `openai.` 厂商前缀过滤
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 渠道「同步模型成功但探测报错不可用」:法兰克福等区域的微调基座模型占满探测候选导致的误报(探测状态与真实可用性不符的根因)
|
||||||
|
- 租户删除:告警命中清理改子查询,日志事件数万条时不再超出 SQL 绑定变量上限导致删除失败;无法解析的任务 payload 记警告跳过并保留原任务,不再永久阻断租户删除
|
||||||
|
|
||||||
|
## [0.1.0]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
@@ -30,7 +197,7 @@
|
|||||||
- HTTP 入口加固:显式读写超时与优雅关闭、请求体上限(面板 1MB / AI 网关 10MB)、登录字段长度上限、登录守卫条目数有界
|
- HTTP 入口加固:显式读写超时与优雅关闭、请求体上限(面板 1MB / AI 网关 10MB)、登录字段长度上限、登录守卫条目数有界
|
||||||
- 出错响应脱敏:内部错误只返回固定文案 + requestId,完整原因写服务端日志;GORM SQL 日志参数化输出,不再记录实参
|
- 出错响应脱敏:内部错误只返回固定文案 + requestId,完整原因写服务端日志;GORM SQL 日志参数化输出,不再记录实参
|
||||||
|
|
||||||
## [0.0.1] - 2026-07-09
|
## [0.0.1]
|
||||||
|
|
||||||
首个版本:自托管 OCI 多租户管理面板后端。
|
首个版本:自托管 OCI 多租户管理面板后端。
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
v0.1.0
|
v0.7.2
|
||||||
|
|||||||
@@ -4,94 +4,202 @@
|
|||||||
|
|
||||||
# OCI Portal
|
# OCI Portal
|
||||||
|
|
||||||
**Oracle Cloud Infrastructure 多租户管理面板**
|
**自托管的 OCI 多租户管理面板与 GenAI 兼容网关**
|
||||||
|
|
||||||

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

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

|
[](docker-compose.yml)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
本仓库为后端;前端工程见 [oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash)(构建产物嵌入本服务成单文件)
|
[界面预览](#界面预览) · [核心能力](#核心能力) · [快速开始](#快速开始) · [生产部署](#生产部署) · [AI 网关](#ai-网关) · [开发](#开发)
|
||||||
|
|
||||||
|
[AI 网关文档](docs/AI网关.md) · [OpenAPI](docs/swagger.yaml) · [更新日志](CHANGELOG.md) · [前端仓库](https://github.com/wangdefaa/oci-portal-dash)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和 OCI GenAI 渠道集中到一个管理界面。Vue 前端通过 `go:embed` 嵌入 Go 服务,Release 以单个二进制和多架构容器镜像交付。
|
||||||
|
|
||||||
|
本仓库为后端与发行仓库;前端源码位于 [oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash)。
|
||||||
|
|
||||||
## 界面预览
|
## 界面预览
|
||||||
|
|
||||||
| 总览 | 登录 |
|
<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、分组、批量测活、账户画像、订阅区域缓存与切换;实例创建与电源操作、VNIC、公网 IP、IPv6、VCN、安全列表、引导卷、块存储挂载、限额与成本查询 |
|
||||||
|
| **自动化与控制台** | 抢机、租户测活、成本同步、AI 渠道探测;执行日志、重叠防护、熔断与结果通知;xterm 串行终端、noVNC 和 OCI 控制台连接两跳 SSH 隧道 |
|
||||||
|
| **身份与审计** | IAM 用户、MFA、API Key、密码策略、SAML、通知收件人与多 Identity Domain;通过 Service Connector Hub 与 Notifications 接收并分类推送 OCI Audit 事件 |
|
||||||
|
| **通知与安全** | Telegram、Webhook、ntfy、Bark、SMTP;AES-256-GCM、JWT、bcrypt、TOTP、OIDC / GitHub 登录、登录锁定、IP 限速、会话撤销和操作审计 |
|
||||||
|
| **AI 网关** | OpenAI Responses、Chat Completions、Embeddings 与 Anthropic Messages;渠道分组、加权路由、熔断探测、模型治理、密钥管理和调用日志 |
|
||||||
|
|
||||||
## 特性
|
## 运行形态
|
||||||
|
|
||||||
- **多租户管理**:多份 OCI API Key 配置集中管理,私钥/口令 AES-256-GCM 加密落库;分组、批量测活、账户类型与订阅信息识别
|
```text
|
||||||
- **资源管理**:实例(含创建/电源操作/更换公网 IP/IPv6/VNIC/引导卷)、VCN/子网/安全列表、块存储、限额与成本查询,多区域/多区间支持
|
浏览器 / API 客户端
|
||||||
- **抢机任务**:cron 周期尝试创建实例直到成功,支持熔断与通知
|
│
|
||||||
- **网页控制台**:实例串行控制台(xterm)与 VNC(noVNC),两跳 SSH 隧道
|
▼
|
||||||
- **AI 网关**:OpenAI / Anthropic 兼容端点转发 OCI GenAI(对话/Responses/Embeddings),号池渠道加权负载均衡、熔断探测、密钥管理与用量日志
|
┌──────────────────── OCI Portal 单个 Go 进程 ────────────────────┐
|
||||||
- **日志回传**:OCI Audit 事件经 Connector Hub → Notifications HTTPS 订阅回传入库,一键创建链路;自定义告警规则(事件类型/来源 IP 白名单/资源/频率阈值)命中即推送
|
│ Vue 3 静态资源(go:embed) │
|
||||||
- **租户治理**:IAM 用户/MFA/API Key 管理、密码策略、身份提供商(SAML)、通知收件人;多 Identity Domain 租户可按域切换管理
|
│ /api/v1 → Gin → Service → OCI SDK │
|
||||||
- **安全**:JWT + bcrypt(凭据变更旧令牌立即失效,可一键撤销全部会话)、TOTP 两步验证、OIDC/GitHub 外部登录、登录锁定、IP 限速、真实 IP 头可配、请求体/超时防护、系统操作审计
|
│ /ai/v1 → 兼容转换与路由 → OCI Generative AI │
|
||||||
- **通知**:模板化推送,五渠道并存(Telegram / Webhook / ntfy / Bark / SMTP),Webhook 可对接飞书、钉钉、Slack、企业微信机器人
|
│ GORM → SQLite(默认)/ MySQL / PostgreSQL(experimental) │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 默认推荐 SQLite 单实例部署。MySQL 和 PostgreSQL 适配仍属 experimental,
|
||||||
|
> 不应据此推断服务支持多副本并发运行。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
|
### Docker Compose(推荐)
|
||||||
|
|
||||||
|
前置条件:Docker、Docker Compose v2、OpenSSL。
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> `.env` 中的 `DATA_KEY` 用于解密 OCI 私钥、口令和渠道凭据。首次生成后必须长期
|
||||||
|
> 保存;升级或重装时不要覆盖,否则已有密文将无法恢复。
|
||||||
|
|
||||||
|
1. 克隆仓库并生成一份需要长期保存的 `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/wangdefaa/oci-portal.git
|
||||||
|
cd oci-portal
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
printf 'DATA_KEY=%s\nJWT_SECRET=%s\nADMIN_PASSWORD=%s\n' \
|
||||||
|
"$(openssl rand -hex 32)" \
|
||||||
|
"$(openssl rand -hex 32)" \
|
||||||
|
"$(openssl rand -base64 24)" > .env
|
||||||
|
chmod 600 .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 准备数据目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p data
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux 使用 bind mount 时,需要让镜像内的 nonroot 用户(uid `65532`)可写;
|
||||||
|
Docker Desktop 用户通常不需要执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo chown 65532:65532 data
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 启动并检查状态:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 查看初始管理员密码并登录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep '^ADMIN_PASSWORD=' .env
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 `http://127.0.0.1:18888`,默认用户名为 `admin`。
|
||||||
|
|
||||||
|
启动异常时查看最近日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs --tail=100 oci-portal
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 请将 `.env` 与 `data/oci-portal.db` 成对备份;恢复时两者必须匹配。
|
||||||
|
|
||||||
### 二进制运行
|
### 二进制运行
|
||||||
|
|
||||||
从 Release 下载对应架构的二进制后:
|
Release 提供 Linux amd64 / arm64 二进制。以下以 amd64 为例;arm64 主机将文件名中的 `amd64` 替换为 `arm64`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DATA_KEY=$(openssl rand -hex 32) JWT_SECRET=$(openssl rand -hex 32) ADMIN_PASSWORD=<初始密码> ./oci-portal-server
|
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
|
||||||
|
|
||||||
|
# 复用上文生成并妥善保存的 .env。
|
||||||
|
set -a
|
||||||
|
. ./.env
|
||||||
|
set +a
|
||||||
|
|
||||||
|
./oci-portal-server-linux-amd64
|
||||||
```
|
```
|
||||||
|
|
||||||
访问 `http://localhost:8080`,用 `admin` 与初始密码登录。
|
默认访问地址为 `http://localhost:8080`。`ADMIN_PASSWORD` 只在数据库中没有用户时创建初始管理员,后续启动不会用它重置密码。
|
||||||
|
|
||||||
### Docker Compose
|
### 源码构建
|
||||||
|
|
||||||
|
源码构建要求 Go 1.26.5、`curl`、`unzip` 和 `sha256sum`。仓库只保留前端占位页,编译完整单文件前应下载 `DASH_VERSION` 指定的前端产物并校验:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 镜像以 distroless nonroot(uid 65532)运行,数据卷需可写,否则 SQLite 报 unable to open database file
|
DASH_TAG="$(tr -d '\r\n' < DASH_VERSION)"
|
||||||
mkdir -p ./data && sudo chown 65532:65532 ./data
|
DASH_BASE="https://github.com/wangdefaa/oci-portal-dash/releases/download/${DASH_TAG}"
|
||||||
DATA_KEY=$(openssl rand -hex 32) JWT_SECRET=$(openssl rand -hex 32) ADMIN_PASSWORD=<初始密码> docker compose up -d
|
|
||||||
|
curl -fL -o dist.zip "${DASH_BASE}/dist.zip"
|
||||||
|
curl -fL -o dist.zip.sha256 "${DASH_BASE}/dist.zip.sha256"
|
||||||
|
sha256sum -c dist.zip.sha256
|
||||||
|
|
||||||
|
rm -rf internal/webui/dist
|
||||||
|
mkdir -p internal/webui/dist
|
||||||
|
unzip -q dist.zip -d internal/webui/dist
|
||||||
|
|
||||||
|
CGO_ENABLED=0 go build -trimpath -o bin/oci-portal-server ./cmd/server
|
||||||
```
|
```
|
||||||
|
|
||||||
默认只监听 `127.0.0.1:18888`;公网访问须置于 TLS 反向代理之后,见下文「反向代理」。
|
本地构建显示 `dev` 版本;Release 工作流会注入正式版本和构建时间。`internal/webui/dist` 中的真实前端产物不应提交到仓库。
|
||||||
|
|
||||||
### 源码构建(单文件,含前端)
|
## 生产部署
|
||||||
|
|
||||||
```bash
|
> [!WARNING]
|
||||||
# 1. 获取前端产物:从前端仓库 Release 下载 dist.zip(或本地 npm run build 后拷入)
|
> 服务本身只提供 HTTP。除本机试用外,应仅监听回环地址或容器内部网络,并由
|
||||||
curl -fL -o dist.zip https://github.com/wangdefaa/oci-portal-dash/releases/latest/download/dist.zip
|
> TLS 反向代理提供 HTTPS;否则管理员密码、JWT、AI 密钥和租户凭据会经明文传输。
|
||||||
rm -rf internal/webui/dist && mkdir -p internal/webui/dist && unzip -q dist.zip -d internal/webui/dist
|
|
||||||
# 2. 编译(免 CGO,可交叉编译;-X 两项注入「设置·关于」页的版本与构建时间,可省略,省略则显示 dev)
|
|
||||||
CGO_ENABLED=0 go build -trimpath \
|
|
||||||
-ldflags "-s -w -X oci-portal/internal/api.buildVersion=v0.0.1 -X oci-portal/internal/api.buildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
||||||
-o bin/oci-portal-server ./cmd/server
|
|
||||||
```
|
|
||||||
|
|
||||||
> 注意:上述解压会覆盖仓库占位文件 `internal/webui/dist/index.html`,提交代码前勿把真实产物带入版本库。
|
### Caddy
|
||||||
|
|
||||||
### 反向代理(公网部署必读)
|
|
||||||
|
|
||||||
面板自身只提供 HTTP,管理员口令、JWT 与租户 API 私钥都会经明文承载。除本机试用外,应让面板仅监听回环地址(compose 示例已默认 `127.0.0.1:18888`),由支持 TLS 的反向代理对外提供 HTTPS。
|
|
||||||
|
|
||||||
Caddy 最小示例(整站反代,自动申请并续期 Let's Encrypt 证书,WebSocket 自动透传):
|
|
||||||
|
|
||||||
```caddyfile
|
```caddyfile
|
||||||
portal.example.com {
|
portal.example.com {
|
||||||
reverse_proxy 127.0.0.1:18888
|
reverse_proxy 127.0.0.1:18888
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
nginx 示例(证书自备;Web Console 的 WebSocket 升级与长超时必须显式配置,否则串行终端连不上或空闲即断):
|
Caddy 会自动处理 WebSocket。使用 nginx、Traefik 或其他反向代理时,还需要满足:
|
||||||
|
|
||||||
|
- 为串行终端和 VNC 转发 WebSocket Upgrade 头
|
||||||
|
- 为 AI 流式响应和控制台连接设置较长的读写超时,建议 `1h`
|
||||||
|
- 整站请求体上限至少为 `10MB`;后端会继续限制面板 API 为 `1MB`、AI 网关为 `10MB`
|
||||||
|
- 将面板内「设置 → 安全 → 真实 IP 请求头」配置为反向代理实际写入的头;系统审计、登录锁定和 IP 限速依赖它
|
||||||
|
- 配置 `PUBLIC_URL` 或面板地址,供 OAuth 回调和 OCI 日志回传链路使用
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>nginx 最小示例</summary>
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
# http 块内:按请求是否升级为 WebSocket 决定 Connection 头
|
|
||||||
map $http_upgrade $connection_upgrade {
|
map $http_upgrade $connection_upgrade {
|
||||||
default upgrade;
|
default upgrade;
|
||||||
"" close;
|
"" close;
|
||||||
@@ -104,7 +212,6 @@ server {
|
|||||||
ssl_certificate /etc/nginx/certs/portal.example.com.crt;
|
ssl_certificate /etc/nginx/certs/portal.example.com.crt;
|
||||||
ssl_certificate_key /etc/nginx/certs/portal.example.com.key;
|
ssl_certificate_key /etc/nginx/certs/portal.example.com.key;
|
||||||
|
|
||||||
# 放行到最大入口(AI 网关 10MB);各入口更细的上限由后端自身执行
|
|
||||||
client_max_body_size 10m;
|
client_max_body_size 10m;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
@@ -114,69 +221,90 @@ server {
|
|||||||
proxy_set_header Connection $connection_upgrade;
|
proxy_set_header Connection $connection_upgrade;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
# WebSocket 空闲与 AI 流式响应都需要长读超时(默认 60s 会断)
|
|
||||||
proxy_read_timeout 1h;
|
proxy_read_timeout 1h;
|
||||||
proxy_send_timeout 1h;
|
proxy_send_timeout 1h;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Traefik 示例(已运行 Traefik 的 Docker 环境,面板容器加 label 接入即可,WebSocket 透明支持):
|
</details>
|
||||||
|
|
||||||
```yaml
|
若前端静态文件与后端分离部署,`/api/*` 和 `/ai/*` 必须代理到后端,其余路径由 SPA 静态服务处理并回退到 `index.html`。Traefik 通过容器网络连接时应直达容器端口 `8080`,无需暴露宿主机端口。
|
||||||
services:
|
|
||||||
oci-portal:
|
|
||||||
image: ghcr.io/wangdefaa/oci-portal:latest
|
|
||||||
# ……环境变量与数据卷同上文 compose 示例;与 Traefik 同一 docker 网络,
|
|
||||||
# Traefik 经容器网络直达 8080,无需(也不应)对外映射 ports
|
|
||||||
labels:
|
|
||||||
- traefik.enable=true
|
|
||||||
- traefik.http.routers.oci-portal.rule=Host(`portal.example.com`)
|
|
||||||
- traefik.http.routers.oci-portal.entrypoints=websecure
|
|
||||||
- traefik.http.routers.oci-portal.tls.certresolver=le # 换成你的 certResolver 名
|
|
||||||
- traefik.http.services.oci-portal.loadbalancer.server.port=8080
|
|
||||||
```
|
|
||||||
|
|
||||||
若前端静态文件由反代直接伺服(不走内嵌页面),则按前缀代理,缺一不可:
|
## AI 网关
|
||||||
|
|
||||||
| 前缀 | 内容 | 何时需要 |
|
面板内置 OpenAI / Anthropic 兼容的 GenAI 网关:独立密钥鉴权(`Authorization: Bearer sk-...` 或 `x-api-key`),支持渠道分组、加权路由、熔断探测、模型黑白名单、内容日志与调用日志。
|
||||||
|
|
||||||
|
| 端点 | 定位 | 流式 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `/api/*` | 面板 REST、Web Console WebSocket、日志回传 webhook | 始终 |
|
| `POST /ai/v1/responses` | OpenAI Responses,无状态主接口(xAI 服务端工具 / MCP) | SSE |
|
||||||
| `/ai/*` | AI 网关(OpenAI / Claude 兼容端点,独立密钥鉴权) | 启用 AI 网关时 |
|
| `POST /ai/v1/chat/completions` | OpenAI Chat Completions 兼容层 | SSE |
|
||||||
| 其余路径 | 前端 SPA 静态文件(404 回退 `index.html`) | 静态分离形态 |
|
| `POST /ai/v1/messages` | Anthropic Messages 转换层 | SSE |
|
||||||
|
| `POST /ai/v1/embeddings` | OpenAI Embeddings | 否 |
|
||||||
|
| `POST /ai/v1/audio/speech` | 文本转语音(xAI Voice) | 否 |
|
||||||
|
| `POST /ai/v1/tts` | 文本转语音(xAI 官方格式) | 否 |
|
||||||
|
| `POST /ai/v1/rerank` | 文档重排(Cohere Rerank) | 否 |
|
||||||
|
| `POST /ai/v1/moderations` | 内容审核(OCI Guardrails) | 否 |
|
||||||
|
| `GET /ai/v1/models` | 当前密钥可见的模型列表 | 否 |
|
||||||
|
|
||||||
- 反代默认追加的 `X-Forwarded-For` 用于还原真实客户端 IP,系统日志留痕、登录锁定与 IP 限速都依赖它
|
协议兼容边界、已知上游限制与逐字段兼容矩阵见 **[AI 网关文档](./docs/AI网关.md)**。
|
||||||
- 请求体上限建议与后端一致:`/api/*` 1MB、`/ai/*` 10MB(Caddy 用 `request_body` 按前缀分层)
|
|
||||||
|
|
||||||
## 环境变量
|
## API 与配置
|
||||||
|
|
||||||
| 变量 | 必填 | 默认值 | 说明 |
|
### API 文档
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `DATA_KEY` | 是 | 无 | 敏感字段加密主密钥(更换后已入库密文无法解密) |
|
| 路径 | 鉴权 | 用途 |
|
||||||
| `JWT_SECRET` | 是 | 无 | 登录令牌签名密钥 |
|
| --- | --- | --- |
|
||||||
| `ADMIN_USERNAME` | 否 | `admin` | 初始管理员用户名 |
|
| `/api/v1/*` | 登录返回的 JWT Bearer Token | 面板管理 API |
|
||||||
| `ADMIN_PASSWORD` | 首次启动是 | 无 | 仅在用户不存在时创建;已存在不重置 |
|
| `/ai/v1/*` | AI 网关密钥 | OpenAI / Anthropic 兼容接口 |
|
||||||
| `ADDR` | 否 | `:8080` | HTTP 监听地址 |
|
| `/api/v1/webhooks/oci-logs/:secret` | URL 中的独立回传密钥 | OCI Notifications 日志回传 |
|
||||||
| `DB_DRIVER` | 否 | `sqlite` | `sqlite` / `mysql` / `postgres`(后两者 experimental) |
|
|
||||||
| `DB_DSN` | 外部库时是 | 无 | MySQL 需 `parseTime=True`;PostgreSQL 标准 DSN |
|
OpenAPI 文件随仓库维护:[`docs/swagger.yaml`](docs/swagger.yaml) · [`docs/swagger.json`](docs/swagger.json)。
|
||||||
| `DB_PATH` | 否 | `oci-portal.db` | SQLite 文件路径 |
|
|
||||||
| `PUBLIC_URL` | 否 | 无 | 面板公网基址,日志回传一键创建链路用 |
|
运行进程时设置 `SWAGGER=1` 可开放 `/swagger/index.html`。Swagger 默认关闭,生产环境建议仅在受控网络内按需开启。接口注释变更后必须重新生成 OpenAPI。
|
||||||
| `HTTPS_PROXY` | 否 | 无 | 出站代理(如 Telegram 通知走代理) |
|
|
||||||
| `TZ` | 否 | 系统 | cron 表达式解释时区(容器内建议显式设置,二进制已嵌 tzdata) |
|
### 环境变量
|
||||||
| `SWAGGER` | 否 | 关 | `1` 时开放 `/swagger/index.html` API 文档(生产建议按需临时开启) |
|
|
||||||
| `GIN_MODE` | 否 | `release` | `debug` / `release`,影响日志与调试输出 |
|
| 变量 | 使用条件 | 默认值 | 说明 |
|
||||||
|
| --- | :---: | --- | --- |
|
||||||
|
| `DATA_KEY` | 必填 | — | 敏感字段加密主密钥;必须持久保存,不能随意轮换 |
|
||||||
|
| `JWT_SECRET` | 必填 | — | JWT 签名密钥;更换会使已有登录令牌失效 |
|
||||||
|
| `ADMIN_USERNAME` | 可选 | `admin` | 初始管理员用户名 |
|
||||||
|
| `ADMIN_PASSWORD` | 首次启动 | — | 仅在数据库无用户时创建管理员,不会重置已有密码 |
|
||||||
|
| `ADDR` | 可选 | `:8080` | HTTP 监听地址 |
|
||||||
|
| `DB_DRIVER` | 可选 | `sqlite` | `sqlite` / `mysql` / `postgres`;后两者为 experimental |
|
||||||
|
| `DB_PATH` | SQLite | `oci-portal.db` | SQLite 文件路径 |
|
||||||
|
| `DB_DSN` | MySQL / PostgreSQL | — | MySQL 需 `parseTime=True`;不要在日志或文档中暴露凭据 |
|
||||||
|
| `PUBLIC_URL` | 可选 | — | 面板公网基址,作为 OAuth 回调和日志回传引导的回退值 |
|
||||||
|
| `TZ` | 可选 | 系统时区 | cron 表达式的解释时区;容器示例使用 `Asia/Shanghai` |
|
||||||
|
| `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | 可选 | — | Go 标准出站代理变量;面板内显式代理配置优先用于对应业务 |
|
||||||
|
| `SWAGGER` | 可选 | 关闭 | 设为 `1` 时开放 Swagger UI |
|
||||||
|
| `GIN_MODE` | 可选 | `release` | `debug` / `release` |
|
||||||
|
|
||||||
|
## 升级与备份
|
||||||
|
|
||||||
|
1. 阅读 [CHANGELOG](CHANGELOG.md),确认目标版本的行为变化。
|
||||||
|
2. 备份 `.env` 和数据库。SQLite Compose 部署建议先停止服务,再复制
|
||||||
|
`data/oci-portal.db`,避免在线复制产生不一致快照。
|
||||||
|
3. 保持原 `DATA_KEY` 不变;只有数据库而没有对应密钥时,敏感字段无法解密。
|
||||||
|
4. Compose 部署执行 `docker compose pull`,再执行 `docker compose up -d`;服务启动时
|
||||||
|
会自动完成数据库迁移。
|
||||||
|
5. 生产环境保持 Swagger 关闭、限制管理面访问来源,并为 OCI API Key 配置最小权限。
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go test ./... # 全量测试
|
gofmt -l .
|
||||||
go vet ./... && gofmt -l .
|
go vet ./...
|
||||||
go tool swag init -g cmd/server/main.go -o docs --parseInternal --parseDependency # 接口注释变更后重新生成 OpenAPI
|
go test ./...
|
||||||
|
|
||||||
|
# Handler 注释变更后重新生成唯一的对外 API 文档。
|
||||||
|
go tool swag init -g cmd/server/main.go -o docs --parseInternal --parseDependency --overridesFile docs/.swaggo
|
||||||
```
|
```
|
||||||
|
|
||||||
API 文档:全部接口带 swaggo 注释,`SWAGGER=1` 启动后访问 `/swagger/index.html`;spec 文件在 `docs/swagger.json|yaml`。
|
- Go 后端规范与目录约定:[`AGENTS.md`](AGENTS.md) · [`.trellis/spec/backend/`](.trellis/spec/backend/)
|
||||||
|
- 前端开发与构建:[oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash)
|
||||||
编码规范与项目约定见 [AGENTS.md](AGENTS.md) 与 [.trellis/spec/](.trellis/spec/)。
|
- 版本变更:[`CHANGELOG.md`](CHANGELOG.md)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -135,7 +135,7 @@ func run() error {
|
|||||||
defer aiGateway.Wait()
|
defer aiGateway.Wait()
|
||||||
defer stopCleanup()
|
defer stopCleanup()
|
||||||
tasks := service.NewTaskService(db, ociConfigs, notifier, settings)
|
tasks := service.NewTaskService(db, ociConfigs, notifier, settings)
|
||||||
ociConfigs.SetTenantCleanupDeps(tasks, logEvents)
|
ociConfigs.SetTenantCleanupDeps(tasks)
|
||||||
tasks.AttachAiGateway(aiGateway)
|
tasks.AttachAiGateway(aiGateway)
|
||||||
aiGateway.SetOnChannelsChanged(tasks.SyncAiProbeTask)
|
aiGateway.SetOnChannelsChanged(tasks.SyncAiProbeTask)
|
||||||
if err := tasks.Start(); err != nil {
|
if err := tasks.Start(); err != nil {
|
||||||
@@ -147,6 +147,8 @@ func run() error {
|
|||||||
console := service.NewConsoleService(ociConfigs)
|
console := service.NewConsoleService(ociConfigs)
|
||||||
proxies := service.NewProxyService(db, cipher)
|
proxies := service.NewProxyService(db, cipher)
|
||||||
defer proxies.Wait()
|
defer proxies.Wait()
|
||||||
|
// 「关于」页存储指标需知数据库形态
|
||||||
|
api.SetAboutRuntime(cfg.DBDriver, cfg.DBPath)
|
||||||
router := api.NewRouter(auth, oauth, ociConfigs, tasks, console, settings, notifier, systemLogs, logEvents, proxies, aiGateway)
|
router := api.NewRouter(auth, oauth, ociConfigs, tasks, console, settings, notifier, systemLogs, logEvents, proxies, aiGateway)
|
||||||
return serveHTTP(cfg.Addr, router)
|
return serveHTTP(cfg.Addr, router)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// swag 全局类型替换:原始 JSON 与联合类型统一渲染为 AnyJSON(任意 JSON 值),
|
||||||
|
// 避免 json.RawMessage 被误渲染成 byte 数组、联合类型暴露内部字段。
|
||||||
|
replace encoding/json.RawMessage oci-portal/internal/aiwire.AnyJSON
|
||||||
|
replace oci-portal/internal/aiwire.RespInput oci-portal/internal/aiwire.AnyJSON
|
||||||
|
replace oci-portal/internal/aiwire.Content oci-portal/internal/aiwire.AnyJSON
|
||||||
|
replace oci-portal/internal/aiwire.StringList oci-portal/internal/aiwire.AnyJSON
|
||||||
+583
@@ -0,0 +1,583 @@
|
|||||||
|
<a id="top"></a>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="assets/logo.svg" width="88" alt="OCI Portal logo">
|
||||||
|
|
||||||
|
# AI 网关
|
||||||
|
|
||||||
|
**将多路 OCI Generative AI 统一为 OpenAI、Anthropic 与 xAI 兼容接口**
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
[快速接入](#quick-start) · [端点一览](#endpoints) · [路由机制](#routing) · [Codex 接入](#codex) · [已知限制](#limitations) · [兼容矩阵](#compatibility)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 网关集中处理密钥鉴权、模型访问控制、渠道调度与协议适配。路径、参数和
|
||||||
|
> 响应结构以 [Swagger YAML](swagger.yaml) 或运行时 Swagger UI 为准;协议差异、
|
||||||
|
> 兼容改写和实测边界以本文为准。
|
||||||
|
|
||||||
|
| 文档属性 | 当前值 |
|
||||||
|
| --- | --- |
|
||||||
|
| 兼容快照 | **2026-07-16** |
|
||||||
|
| API 基址 | `/ai/v1` |
|
||||||
|
| 首选对话协议 | OpenAI Responses |
|
||||||
|
| 会话模式 | 无状态,客户端携带完整上下文 |
|
||||||
|
|
||||||
|
<a id="quick-start"></a>
|
||||||
|
|
||||||
|
## 快速接入
|
||||||
|
|
||||||
|
### 基础地址与鉴权
|
||||||
|
|
||||||
|
网关密钥在管理面板中创建。连接信息如下:
|
||||||
|
|
||||||
|
| 项目 | 配置 |
|
||||||
|
| --- | --- |
|
||||||
|
| Base URL | `https://<网关地址>/ai/v1` |
|
||||||
|
| Bearer 鉴权 | `Authorization: Bearer sk-...` |
|
||||||
|
| API Key 鉴权 | `x-api-key: sk-...` |
|
||||||
|
|
||||||
|
密钥可绑定渠道分组和模型白名单。全局模型黑名单会同时作用于模型列表、
|
||||||
|
请求路由和探测候选;开启「过滤弃用模型」后,OCI 已宣布弃用的模型也会从
|
||||||
|
列表与路由中移除。
|
||||||
|
|
||||||
|
可先用模型列表验证地址与密钥:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://<网关地址>/ai/v1/models" \
|
||||||
|
-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>
|
||||||
|
|
||||||
|
## 端点一览
|
||||||
|
|
||||||
|
| 协议域 | 端点 | 角色 | 流式 |
|
||||||
|
| --- | --- | --- | :---: |
|
||||||
|
| 对话 | `POST /ai/v1/responses` | OpenAI Responses,无状态主接口 | SSE |
|
||||||
|
| 对话 | `POST /ai/v1/chat/completions` | OpenAI Chat Completions 兼容层 | SSE |
|
||||||
|
| 对话 | `POST /ai/v1/messages` | Anthropic Messages 转换层 | SSE |
|
||||||
|
| 向量 | `POST /ai/v1/embeddings` | OpenAI Embeddings | — |
|
||||||
|
| 语音 | `POST /ai/v1/audio/speech` | OpenAI Audio Speech 外壳 | — |
|
||||||
|
| 语音 | `POST /ai/v1/tts` | xAI TTS 格式转换层 | — |
|
||||||
|
| 检索 | `POST /ai/v1/rerank` | Cohere / Jina 风格文档重排 | — |
|
||||||
|
| 安全 | `POST /ai/v1/moderations` | OpenAI 外壳映射 OCI Guardrails | — |
|
||||||
|
| 发现 | `GET /ai/v1/models` | 当前密钥可见模型列表 | — |
|
||||||
|
|
||||||
|
### 如何选择协议
|
||||||
|
|
||||||
|
| 使用场景 | 推荐接口 |
|
||||||
|
| --- | --- |
|
||||||
|
| 新客户端、推理模型、服务端工具 | **Responses** |
|
||||||
|
| Anthropic SDK、Claude 生态客户端 | **Messages** |
|
||||||
|
| 仅支持旧 OpenAI 对话协议的客户端 | **Chat Completions** |
|
||||||
|
| 向量、语音、重排与安全审核 | 对应专用端点 |
|
||||||
|
|
||||||
|
<a id="routing"></a>
|
||||||
|
|
||||||
|
## 路由与全局行为
|
||||||
|
|
||||||
|
### 请求链路
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[客户端] --> B[网关密钥鉴权]
|
||||||
|
B --> C[分组、白名单与全局过滤]
|
||||||
|
C --> D[模型与能力匹配]
|
||||||
|
D --> E[选择最小 Priority]
|
||||||
|
E --> F[同优先级按 Weight 加权]
|
||||||
|
F --> G[OCI GenAI]
|
||||||
|
G -. 可重试且流未建立 .-> D
|
||||||
|
```
|
||||||
|
|
||||||
|
单次请求最多尝试三个渠道。模型不可用、限流、上游服务错误或网络错误可触发
|
||||||
|
换渠道;流式连接建立后不会切换渠道重试。
|
||||||
|
|
||||||
|
### 全局兼容边界
|
||||||
|
|
||||||
|
| 主题 | 当前行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| 会话状态 | 网关不保存会话历史,客户端必须在每次请求中携带完整上下文 |
|
||||||
|
| 上游存储 | Responses 请求始终强制 `store:false` |
|
||||||
|
| 有状态字段 | Responses 拒绝非空 `previous_response_id`、非 `null` 的 `conversation` 和 `background:true` |
|
||||||
|
| 对话上游 | 对话请求统一进入 OCI OpenAI 兼容面,当前供给以 `xai.`、`meta.`、`openai.` 前缀模型为主 |
|
||||||
|
| 推理强度 | Responses `reasoning.effort`、Messages `output_config.effort`、Chat `reasoning_effort` 会传给上游;可用档位由模型决定 |
|
||||||
|
| 服务端工具 | Responses 支持 `web_search`、`x_search`、`code_interpreter` 和远程 `mcp`;命名容器管理与 File Search 不提供 |
|
||||||
|
| 文件输入 | `input_file` 会被 OCI ZDR 形态拒绝,详见[已知限制](#limitations) |
|
||||||
|
| 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>
|
||||||
|
|
||||||
|
## Codex 接入
|
||||||
|
|
||||||
|
### 主配置
|
||||||
|
|
||||||
|
自定义模型提供方必须写在用户级 `~/.codex/config.toml`。Codex 的项目级
|
||||||
|
`.codex/config.toml` 不允许改写 `model_provider` 与 `model_providers`。
|
||||||
|
|
||||||
|
```toml
|
||||||
|
model = "xai.grok-4.3"
|
||||||
|
model_provider = "oci"
|
||||||
|
|
||||||
|
[model_providers.oci]
|
||||||
|
name = "oci-portal"
|
||||||
|
base_url = "https://<网关地址>/ai/v1"
|
||||||
|
env_key = "OCI_PORTAL_KEY"
|
||||||
|
wire_api = "responses"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自定义子代理
|
||||||
|
|
||||||
|
新版 Codex 可在 `~/.codex/agents/` 或项目级 `.codex/agents/` 放置独立 TOML
|
||||||
|
文件,并为子代理覆盖模型。每个文件都需要 `name`、`description` 和
|
||||||
|
`developer_instructions`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# .codex/agents/oci-worker.toml
|
||||||
|
name = "oci-worker"
|
||||||
|
description = "通过 OCI Portal 网关执行通用开发任务。"
|
||||||
|
developer_instructions = """
|
||||||
|
完成被分配的开发任务,保持改动聚焦,并返回验证结果。
|
||||||
|
"""
|
||||||
|
model = "xai.grok-4.3"
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> `Codex CLI 0.144.1` 已实测主会话和 multi-agent 子代理全链路可用。
|
||||||
|
> 内置 worker 可能先尝试 `gpt-5.6-luna` 或 `gpt-5.4`;网关没有对应渠道时
|
||||||
|
> 会出现少量 404,随后由 Codex 回落到可用模型。自定义 agent 的模型覆盖是否
|
||||||
|
> 直接生效取决于 Codex 版本;0.144.1 的实测主要依赖自动回落。
|
||||||
|
|
||||||
|
Codex 工具兼容现状:
|
||||||
|
|
||||||
|
| 工具形态 | 网关处理 | 验证状态 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `function` | 原样透传 | 可用 |
|
||||||
|
| `namespace` | 子工具拍平为限定名 `function`,响应时还原 | multi-agent 已端到端实测;MCP 同路径有单测 |
|
||||||
|
| `custom` | 顶层普通工具转换为 `function`,调用与历史记录双向回转 | 可用,但存在降级边界 |
|
||||||
|
| `custom:apply_patch` | 直接剥离,让模型回落到其他编辑方式 | 有意限制 |
|
||||||
|
| `tool_search` | namespace 已完整展开,目录搜索语义冗余,直接剥离 | 有意降级 |
|
||||||
|
|
||||||
|
配置语法参考 [Codex Subagents](https://learn.chatgpt.com/docs/agent-configuration/subagents)
|
||||||
|
与 [Codex Advanced Configuration](https://learn.chatgpt.com/docs/config-file/config-advanced)。
|
||||||
|
|
||||||
|
<a id="limitations"></a>
|
||||||
|
|
||||||
|
## 已知限制
|
||||||
|
|
||||||
|
### `instructions` / `tools` 大体量流式断流
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> `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 | 保险丝默认开启;超过 `60 KB` 时改走非流式上游,并合成最小 SSE 序列 | 结果语义保留,但不再增量输出 |
|
||||||
|
| Chat Completions / Messages | 客户端尚未收到内容就断流时,自动改用非流式重做 | 合成对应 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>
|
||||||
|
|
||||||
|
### `multi-agent` 加密推理内容流式断流
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> 当 `multi-agent` 请求同时启用 `stream: true` 与
|
||||||
|
> `include: ["reasoning.encrypted_content"]` 时,上游可能在序列化大体量
|
||||||
|
> `encrypted_content` 事件期间静默断开连接,且不返回 `error` 或终态事件。
|
||||||
|
>
|
||||||
|
> 已复现场景中的断点位于 `output_index: 8` 附近:第三次搜索结束后,上游准备
|
||||||
|
> 发送较大的加密推理块时连接被中止。`multi-agent` 产生的加密推理内容体积较大,
|
||||||
|
> 因而更容易暴露该上游流式序列化缺陷。
|
||||||
|
|
||||||
|
### ZDR 与文件输入
|
||||||
|
|
||||||
|
Responses 的 `input_file` 内容块(`file_url` / `file_data`)实测会被上游拒绝:
|
||||||
|
|
||||||
|
```text
|
||||||
|
File content is currently unsupported for ZDR customers
|
||||||
|
```
|
||||||
|
|
||||||
|
网关强制 `store:false`,属于 ZDR 请求形态,因此当前不能通过该端点上传或引用文件。
|
||||||
|
|
||||||
|
<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 Chat Completions](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create)
|
||||||
|
- [OpenAI Embeddings](https://developers.openai.com/api/reference/resources/embeddings/methods/create)
|
||||||
|
- [OpenAI Audio Speech](https://developers.openai.com/api/reference/resources/audio/methods/speech)
|
||||||
|
- [OpenAI Moderations](https://developers.openai.com/api/reference/resources/moderations/methods/create)
|
||||||
|
- [Anthropic Messages](https://platform.claude.com/docs/en/api/messages/create)
|
||||||
|
- [Cohere Rerank](https://docs.cohere.com/reference/rerank)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
| 标记 | 含义 |
|
||||||
|
| :---: | --- |
|
||||||
|
| ✅ | 网关直接支持 |
|
||||||
|
| ➡️ | 原样保留或透传;是否生效由 OCI 上游决定 |
|
||||||
|
| 🔄 | 网关执行字段或协议转换后支持 |
|
||||||
|
| ◐ | 部分支持、存在前置条件或语义降级 |
|
||||||
|
|
||||||
|
<a id="compat-responses"></a>
|
||||||
|
|
||||||
|
### OpenAI Responses
|
||||||
|
|
||||||
|
**`POST /ai/v1/responses` · 无状态主接口**
|
||||||
|
|
||||||
|
网关以原始 JSON 为基底保留未知字段,但会强制关闭上游存储,并执行下表列出的
|
||||||
|
Codex 工具兼容改写。请求会重新编码,不承诺字节级原样转发。
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary><strong>核心字段</strong></summary>
|
||||||
|
|
||||||
|
| 标准字段 | 状态 | 网关行为 |
|
||||||
|
| --- | :---: | --- |
|
||||||
|
| `model` | ◐ | 必填;通过密钥白名单并匹配可用渠道后送往上游 |
|
||||||
|
| `input` | ➡️ | 支持字符串或 item 数组;普通内容保留,Codex 工具历史项可能改写 |
|
||||||
|
| `instructions`、`max_output_tokens` | ➡️ | 类型可解析后保留,不做范围或模型能力校验 |
|
||||||
|
| `temperature`、`top_p` | ➡️ | 保留,不做取值范围校验 |
|
||||||
|
| `parallel_tool_calls` | ◐ | 通常保留;若全部工具被剥离,则与 `tool_choice` 一并删除 |
|
||||||
|
| `text` / `text.format` / `text.verbosity` | ➡️ | 整个对象保留;是否生效由 OCI 模型决定 |
|
||||||
|
| `reasoning` | ➡️ | 整个对象保留;`effort` 不校验档位 |
|
||||||
|
| `tool_choice` | ◐ | 普通形态保留;namespace 对象会重限定,全部工具被剥离时删除 |
|
||||||
|
| `store` | 🔄 | 无论客户端传什么,上游请求都强制改写为 `false` |
|
||||||
|
| `stream` | ◐ | 支持 SSE;`instructions` 与 `tools` 的原始 JSON 值合计超过保险丝阈值时,预防性改走非流式上游(默认开启、`60 KB`,见[已知限制](#limitations)) |
|
||||||
|
| 其余标准与未知顶层字段 | ➡️ | `context_management`、`include`、`metadata`、`prompt`、`prompt_cache_key`、`service_tier`、`truncation`、`user` 等均保留,由 OCI 决定是否接受 |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>工具兼容</strong></summary>
|
||||||
|
|
||||||
|
| 工具或参数 | 状态 | 网关行为 |
|
||||||
|
| --- | :---: | --- |
|
||||||
|
| `tools[].type=function` | ➡️ | 非流式与流式均可,工具对象保留 |
|
||||||
|
| `web_search` / `x_search` / `code_interpreter` | ◐ | Oracle 文档化的 xAI 服务端工具;参数与限制遵循 [xAI 规格](https://docs.oracle.com/en-us/iaas/Content/generative-ai/get-started-agents.htm#xai-compatible-tools) |
|
||||||
|
| `tools[].type=mcp` | ➡️ | 远程 MCP 由上游直连,`server_url`、`require_approval`、`authorization` 等保留 |
|
||||||
|
| `tools[].type=namespace` | 🔄 | 子 `function` 上提并限定命名;响应、历史调用和 `tool_choice` 会反向还原;组内非 `function` 子工具被剥离 |
|
||||||
|
| `tools[].type=custom` | ◐ | 顶层非 `apply_patch` 工具转为带 `input` schema 的 `function`;响应与多轮历史双向回转;`format` 会删除 |
|
||||||
|
| `custom:apply_patch` | ◐ | 整体剥离;Grok 系未针对 Codex 补丁格式训练,模型应回落其他编辑方式 |
|
||||||
|
| `tools[].type=tool_search` | ◐ | 请求可被接受,但工具本身直接剥离 |
|
||||||
|
| `web_search.external_web_access=true` | 🔄 | 删除上游不识别的字段,保留 `web_search` |
|
||||||
|
| `web_search.external_web_access=false` | ◐ | 上游没有“仅缓存检索”对应能力,按不越权原则剥离整个工具 |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
本地拒绝(返回 400):
|
||||||
|
|
||||||
|
- 非空 `previous_response_id`
|
||||||
|
- 非 `null` 的 `conversation`
|
||||||
|
- `background:true`
|
||||||
|
- 未列入白名单的工具类型,例如 `web_search_preview`、`file_search`、
|
||||||
|
`computer`、`image_generation`、`shell`
|
||||||
|
|
||||||
|
响应边界:
|
||||||
|
|
||||||
|
- 无工具还原且未触发流式升级时,普通响应保持直通语义
|
||||||
|
- namespace / custom 调用会在非流式响应与命中的 SSE `data` 事件中定向还原
|
||||||
|
- 普通 SSE 不补 `data: [DONE]`,reasoning 增量不会被过滤
|
||||||
|
- 未知模型返回 404,无可用渠道返回 503
|
||||||
|
- 上游错误使用 OpenAI 风格错误外壳,但不保证字段与标准 OpenAI 完全一致
|
||||||
|
|
||||||
|
<a id="compat-chat"></a>
|
||||||
|
|
||||||
|
### OpenAI Chat Completions
|
||||||
|
|
||||||
|
**`POST /ai/v1/chat/completions` · 存量客户端兼容层**
|
||||||
|
|
||||||
|
请求先转换为 Responses,再将 OCI Responses 响应桥接回 Chat Completions。
|
||||||
|
只有下表字段会进入上游请求。
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>展开字段矩阵</strong></summary>
|
||||||
|
|
||||||
|
| 标准字段 | 状态 | 网关行为 |
|
||||||
|
| --- | :---: | --- |
|
||||||
|
| `model` | ◐ | 必填,受密钥白名单和可用渠道限制 |
|
||||||
|
| `messages` | 🔄 | 必填,转换为 Responses `input` / `instructions` |
|
||||||
|
| `system` / `developer` 消息 | ◐ | 文本按顺序合并为 `instructions`;块数组中的非文本内容忽略 |
|
||||||
|
| `user` / `assistant` 文本 | 🔄 | 字符串和 `text` 块分别转为 `input_text` / `output_text` |
|
||||||
|
| `image_url` | ◐ | URL 或 data URI 转为 `input_image`;`detail` 忽略 |
|
||||||
|
| assistant `tool_calls` / `role=tool` | 🔄 | 转为 `function_call` / `function_call_output` |
|
||||||
|
| `max_completion_tokens` | 🔄 | 转为 `max_output_tokens`,优先于 `max_tokens` |
|
||||||
|
| `max_tokens` | 🔄 | 未提供 `max_completion_tokens` 时转为 `max_output_tokens` |
|
||||||
|
| `temperature`、`top_p`、`parallel_tool_calls` | ◐ | 写入 Responses,不校验范围或模型能力 |
|
||||||
|
| `stream` | 🔄 | Responses SSE 转为 `chat.completion.chunk`,末尾补 `data: [DONE]` |
|
||||||
|
| `stream_options.include_usage` | 🔄 | 控制终块后的独立 usage 块 |
|
||||||
|
| `tools[].type=function` | ◐ | 支持 `name`、`description`、`parameters`;`strict` 忽略 |
|
||||||
|
| `tool_choice` | ◐ | 支持 `auto` / `none` / `required` 和具名 function |
|
||||||
|
| `response_format` | ◐ | `json_object`、`json_schema` 转为 Responses `text.format` |
|
||||||
|
| `reasoning_effort` | ◐ | 转小写后映射为 `reasoning.effort` |
|
||||||
|
| `store` | 🔄 | 客户端取值忽略,上游始终使用 `store:false` |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
不支持(返回 400):`input_audio`、`file`、`refusal` 等消息内容块,以及
|
||||||
|
`function` 之外的工具类型。
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>静默忽略字段</strong></summary>
|
||||||
|
|
||||||
|
消息级 `name` / `refusal` / `audio` / 旧式 `function_call`;`stop`、`seed`、
|
||||||
|
`n`、`frequency_penalty`、`presence_penalty`、`logprobs`、`top_logprobs`、
|
||||||
|
`logit_bias`;`user`、`audio`、`modalities`、`prediction`、`metadata`、
|
||||||
|
`moderation`、`prompt_cache_key`、`safety_identifier`、`service_tier`、
|
||||||
|
`verbosity`、`web_search_options`、`stream_options.include_obfuscation`
|
||||||
|
及其他未知字段。
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
响应边界:
|
||||||
|
|
||||||
|
- 非流式固定生成一个 `choices[0]`;文本合并,函数调用转为 `tool_calls`
|
||||||
|
- `finish_reason` 只生成 `stop`、`tool_calls`、`length`
|
||||||
|
- reasoning、logprobs、refusal、annotations、audio、service tier 和 system fingerprint 不返回
|
||||||
|
- usage 保留 `prompt_tokens`、`completion_tokens`、`total_tokens` 与
|
||||||
|
`prompt_tokens_details.cached_tokens`
|
||||||
|
- 已开始输出的流中断不会转换成标准 SSE 错误事件
|
||||||
|
|
||||||
|
<a id="compat-messages"></a>
|
||||||
|
|
||||||
|
### Anthropic Messages
|
||||||
|
|
||||||
|
**`POST /ai/v1/messages` · Anthropic 协议转换层**
|
||||||
|
|
||||||
|
支持 `Authorization: Bearer` 与 `x-api-key`,但不校验或使用
|
||||||
|
`anthropic-version`、`anthropic-beta` 请求头。
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>展开顶层字段矩阵</strong></summary>
|
||||||
|
|
||||||
|
| 标准字段 | 状态 | 网关行为 |
|
||||||
|
| --- | :---: | --- |
|
||||||
|
| `model` | ◐ | 用于模型与渠道选择;空字符串通常最终返回模型不存在 |
|
||||||
|
| `max_tokens` | 🔄 | 缺省或 ≤0 时使用 8192,再转为 `max_output_tokens` |
|
||||||
|
| `messages` | ◐ | 必须非空;角色、交替顺序和空内容不做完整校验 |
|
||||||
|
| `system` | ◐ | 支持字符串或 text 块数组;文本块拼接,附加字段忽略 |
|
||||||
|
| `temperature`、`top_p` | ◐ | 写入 Responses,不校验范围或模型能力 |
|
||||||
|
| `stream` | 🔄 | Responses SSE 桥接为 Anthropic 标准事件序列 |
|
||||||
|
| `tools` | ◐ | 每个工具转为 Responses `function`;服务端工具类型不保留原语义 |
|
||||||
|
| `tool_choice` | ◐ | 支持 `auto`、`any`、`none`、具名 `tool` |
|
||||||
|
| `output_config.effort` | 🔄 | 转小写后映射为 `reasoning.effort` |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
内容块兼容:
|
||||||
|
|
||||||
|
| `messages[].content` | 状态 | 网关行为 |
|
||||||
|
| --- | :---: | --- |
|
||||||
|
| 字符串 / `text` | 🔄 | user 转 `input_text`,assistant 历史转 `output_text` |
|
||||||
|
| `image` | ◐ | 仅支持 `base64` 与 `url` source |
|
||||||
|
| `tool_use` | 🔄 | 转为 `function_call`,保留 ID、名称与输入 |
|
||||||
|
| `tool_result` | ◐ | 转为 `function_call_output`;块数组只拼接 text,`is_error` 与非文本结果丢失 |
|
||||||
|
| `null` / 空块数组 | ◐ | 消息可能从上游 input 中消失,不返回参数错误 |
|
||||||
|
|
||||||
|
不支持(返回 400):`document`、服务端工具结果及未知内容块。历史
|
||||||
|
`thinking` / `redacted_thinking` 会删除。
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>静默忽略字段</strong></summary>
|
||||||
|
|
||||||
|
`top_k`、`stop_sequences`、`metadata`、`thinking`、`output_config` 其余子字段、
|
||||||
|
`cache_control`、`container`、`inference_geo`、`service_tier` 及其他未知顶层字段。
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
响应边界:
|
||||||
|
|
||||||
|
- 仅将 Responses `output_text` 转为 `text`,`function_call` 转为 `tool_use`
|
||||||
|
- `stop_reason` 只生成 `end_turn`、`tool_use`、`max_tokens`
|
||||||
|
- 不生成 Anthropic thinking / signature 内容块
|
||||||
|
- usage 只保留 `input_tokens`、`output_tokens` 与 `cache_read_input_tokens`
|
||||||
|
- 流式上游错误与无终态断流会转成 Anthropic `error` 事件
|
||||||
|
|
||||||
|
<a id="compat-embeddings"></a>
|
||||||
|
|
||||||
|
### OpenAI Embeddings
|
||||||
|
|
||||||
|
**`POST /ai/v1/embeddings` · 向量化专用端点**
|
||||||
|
|
||||||
|
| 标准字段 | 状态 | 网关行为 |
|
||||||
|
| --- | :---: | --- |
|
||||||
|
| `model` | ◐ | 必填,受白名单限制,且需要 `EMBEDDING` 能力渠道 |
|
||||||
|
| 字符串 `input` | ✅ | 包装为单个输入后调用 OCI |
|
||||||
|
| 字符串数组 `input` | ✅ | 按原顺序调用 OCI |
|
||||||
|
| `dimensions` | ◐ | 映射为 OCI 输出维度,不校验范围或模型能力 |
|
||||||
|
| `encoding_format=float` | ✅ | 返回 float 数组;省略时相同 |
|
||||||
|
|
||||||
|
不支持(返回 400):token ID 数组、空数组、`null` 和
|
||||||
|
`encoding_format=base64`。`user` 与其他未知字段静默忽略。
|
||||||
|
|
||||||
|
响应使用标准 `object:"list"` 外壳,向量为 `float32` 数组,不支持流式。
|
||||||
|
|
||||||
|
<a id="compat-audio"></a>
|
||||||
|
|
||||||
|
### 语音生成
|
||||||
|
|
||||||
|
两个端点最终使用同一 OCI xAI TTS 上游与渠道调度:
|
||||||
|
|
||||||
|
#### OpenAI Audio Speech
|
||||||
|
|
||||||
|
**`POST /ai/v1/audio/speech`**
|
||||||
|
|
||||||
|
| 标准字段 | 状态 | 网关行为 |
|
||||||
|
| --- | :---: | --- |
|
||||||
|
| `model` | ◐ | 必填,受白名单限制,需要 `TTS` 能力渠道;当前为 `xai.grok-tts` |
|
||||||
|
| `input` | ◐ | 必填非空,随后保留 |
|
||||||
|
| `voice` | ➡️ | 使用 xAI 音色 `ara` / `eve` / `leo` / `rex` / `sal` |
|
||||||
|
| `response_format` | ➡️ | 实测 `mp3` 可用,其余格式由上游决定 |
|
||||||
|
| `language`(扩展) | 🔄 | 上游必填;缺省时注入 `"auto"` |
|
||||||
|
| `speed`、`instructions` 与未知字段 | ➡️ | 保留,是否生效由上游决定 |
|
||||||
|
|
||||||
|
#### xAI TTS
|
||||||
|
|
||||||
|
**`POST /ai/v1/tts`**
|
||||||
|
|
||||||
|
接受 [xAI 官方 TTS 格式](https://docs.x.ai/developers/model-capabilities/audio/text-to-speech),
|
||||||
|
转换为 OpenAI 兼容形态后进入同一上游。
|
||||||
|
|
||||||
|
| 字段 | 状态 | 网关行为 |
|
||||||
|
| --- | :---: | --- |
|
||||||
|
| `text` | 🔄 | 必填非空,转换为 `input` |
|
||||||
|
| `language` | ◐ | 必填,接受 BCP-47 或 `auto` |
|
||||||
|
| `voice_id` | 🔄 | 转为 `voice`;缺省交给上游默认值 `eve` |
|
||||||
|
| `output_format` | ➡️ | `{codec, sample_rate, bit_rate}` 对象保留,实测生效 |
|
||||||
|
| `speed` | ➡️ | 保留,实测可用 |
|
||||||
|
| `model`(网关扩展) | ◐ | 缺省注入 `xai.grok-tts`,可覆盖,受白名单限制 |
|
||||||
|
| 其余未知字段 | ➡️ | 保留,是否生效由上游决定 |
|
||||||
|
|
||||||
|
共同响应边界:
|
||||||
|
|
||||||
|
- 成功响应为一次性完整音频,`Content-Type` 透传上游,缺省 `audio/mpeg`
|
||||||
|
- 不提供 HTTP 流式音频或 WebSocket 代理
|
||||||
|
- 无 token 用量口径,调用日志只记时延与渠道
|
||||||
|
|
||||||
|
<a id="compat-rerank"></a>
|
||||||
|
|
||||||
|
### Rerank
|
||||||
|
|
||||||
|
**`POST /ai/v1/rerank` · Cohere / Jina 风格协议**
|
||||||
|
|
||||||
|
上游走 OCI typed 面,模型为 `cohere.rerank-v4.0-pro` 或 `-fast`。
|
||||||
|
|
||||||
|
| 字段 | 状态 | 网关行为 |
|
||||||
|
| --- | :---: | --- |
|
||||||
|
| `model` | ◐ | 必填,受白名单限制,需要 `RERANK` 能力渠道 |
|
||||||
|
| `query` | ✅ | 必填非空 |
|
||||||
|
| `documents` | ◐ | 仅接受字符串数组;旧版 `{"text": ...}` 对象数组返回 400 |
|
||||||
|
| `top_n` | ✅ | 可选,限制返回条数 |
|
||||||
|
| `return_documents` | ✅ | 为 `true` 时回带原文 |
|
||||||
|
|
||||||
|
`max_tokens_per_doc` 与未知字段静默忽略。响应按相关度降序,
|
||||||
|
`results[].index` 指向输入下标,`relevance_score` 为 0~1 浮点;无 token 用量口径。
|
||||||
|
|
||||||
|
<a id="compat-moderations"></a>
|
||||||
|
|
||||||
|
### Moderations
|
||||||
|
|
||||||
|
**`POST /ai/v1/moderations` · OpenAI 外壳映射 OCI Guardrails**
|
||||||
|
|
||||||
|
| 标准字段 | 状态 | 网关行为 |
|
||||||
|
| --- | :---: | --- |
|
||||||
|
| 字符串 `input` | ✅ | 单条审核 |
|
||||||
|
| 字符串数组 `input` | ✅ | 逐条审核,单次 1~8 条 |
|
||||||
|
|
||||||
|
不支持(返回 400):多模态 input、空字符串条目、空数组或超过 8 条。
|
||||||
|
`model` 接受但忽略,不参与白名单判断。
|
||||||
|
|
||||||
|
响应边界:
|
||||||
|
|
||||||
|
- categories 使用 OCI 原生维度 `overall` / `blocklist` / `prompt_injection`
|
||||||
|
- 任一维度得分 ≥ 0.5 时 `flagged=true`
|
||||||
|
- PII 命中放在扩展字段 `results[].pii`,包含 `text`、`label`、`score`、
|
||||||
|
`offset` 与 `length`,不参与 `flagged`
|
||||||
|
- 实测中文人名与手机号识别较弱,英文 PII 识别正常
|
||||||
|
- 响应 `model` 恒为 `oci-guardrails`
|
||||||
|
|
||||||
|
<a id="compat-models"></a>
|
||||||
|
|
||||||
|
### Models
|
||||||
|
|
||||||
|
**`GET /ai/v1/models` · 当前密钥可见模型列表**
|
||||||
|
|
||||||
|
响应使用 OpenAI Models 列表外壳,只返回同时通过以下条件的模型:
|
||||||
|
|
||||||
|
1. 存在于当前渠道目录
|
||||||
|
2. 匹配密钥绑定的渠道分组
|
||||||
|
3. 不在全局模型黑名单
|
||||||
|
4. 命中密钥模型白名单(配置时)
|
||||||
|
5. 未被「过滤弃用模型」开关排除
|
||||||
|
|
||||||
|
网关不提供标准的单模型检索端点。
|
||||||
|
|
||||||
|
<a id="implementation"></a>
|
||||||
|
|
||||||
|
## 附录:实现索引
|
||||||
|
|
||||||
|
| 端点 / 能力 | 主要实现 |
|
||||||
|
| --- | --- |
|
||||||
|
| Responses 直通与 Codex 工具兼容 | [`airesponses.go`](../internal/service/airesponses.go) · [`responses.go`](../internal/aiwire/responses.go) |
|
||||||
|
| Chat Completions 转换 | [`chatresponses.go`](../internal/service/chatresponses.go) · [`openai.go`](../internal/aiwire/openai.go) |
|
||||||
|
| Anthropic Messages 转换 | [`anthresponses.go`](../internal/service/anthresponses.go) · [`anthropic.go`](../internal/aiwire/anthropic.go) |
|
||||||
|
| 渠道路由与 Embeddings | [`aigateway_chat.go`](../internal/service/aigateway_chat.go) |
|
||||||
|
| TTS 上游 | [`genai_speech.go`](../internal/oci/genai_speech.go) · [`aigateway_extras.go`](../internal/service/aigateway_extras.go) |
|
||||||
|
| Rerank / Moderations 上游 | [`genai_guard.go`](../internal/oci/genai_guard.go) · [`rerank.go`](../internal/aiwire/rerank.go) · [`moderations.go`](../internal/aiwire/moderations.go) |
|
||||||
|
| HTTP Handler 与流式桥接 | [`aigateway.go`](../internal/api/aigateway.go) · [`aigateway_extras.go`](../internal/api/aigateway_extras.go) |
|
||||||
|
|
||||||
|
本文是兼容性快照,不替代 Swagger。标准接口、Codex 客户端与 OCI 上游均可能
|
||||||
|
变化,最终行为以当前版本代码、运行时 Swagger 和实测结果为准。
|
||||||
|
|
||||||
|
[返回顶部](#top)
|
||||||
+3854
-467
File diff suppressed because it is too large
Load Diff
+3854
-467
File diff suppressed because it is too large
Load Diff
+2590
-406
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -119,8 +119,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
|
|||||||
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/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ type MessagesRequest struct {
|
|||||||
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
|
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
|
||||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||||
Thinking json.RawMessage `json:"thinking,omitempty"`
|
Thinking json.RawMessage `json:"thinking,omitempty"`
|
||||||
|
// OutputConfig 是 Anthropic 官方输出控制(effort 等);网关映射 effort 到 OCI reasoningEffort。
|
||||||
|
OutputConfig *AnthOutputConfig `json:"output_config,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthOutputConfig 是 Anthropic Messages 顶层 output_config 对象。
|
||||||
|
type AnthOutputConfig struct {
|
||||||
|
Effort string `json:"effort,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SystemText 把顶层 system(string 或块数组)拍平为文本。
|
// SystemText 把顶层 system(string 或块数组)拍平为文本。
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package aiwire
|
||||||
|
|
||||||
|
// AnyJSON 仅供 swagger 文档渲染:经 .swaggo 全局替换,代表任意 JSON 值
|
||||||
|
// (json.RawMessage 与 string/数组联合类型),运行时代码不使用。
|
||||||
|
type AnyJSON struct{}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package aiwire
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// ModerationsRequest 是 /ai/v1/moderations 请求体;input 兼容 OpenAI 的
|
||||||
|
// string 与 []string 两种形态,model 字段接受但忽略(上游为服务级 API)。
|
||||||
|
type ModerationsRequest struct {
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
Input json.RawMessage `json:"input"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModerationPii 是一处 PII 命中(OCI 扩展,OpenAI 协议无对应物)。
|
||||||
|
type ModerationPii struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
Length int `json:"length"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModerationResult 是一条输入的审核结果:categories / category_scores 用
|
||||||
|
// OCI 原生维度(overall / blocklist / prompt_injection),pii 为扩展字段。
|
||||||
|
type ModerationResult struct {
|
||||||
|
Flagged bool `json:"flagged"`
|
||||||
|
Categories map[string]bool `json:"categories"`
|
||||||
|
CategoryScores map[string]float64 `json:"category_scores"`
|
||||||
|
Pii []ModerationPii `json:"pii,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModerationsResponse 是 /ai/v1/moderations 响应体(OpenAI moderations 外壳)。
|
||||||
|
type ModerationsResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Results []ModerationResult `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpeechRequest 描述 /ai/v1/audio/speech 请求体的已知字段(文档用途)。
|
||||||
|
// 直通端点实际按原样透传,未列字段与 xAI 专属参数(如 output_format)同样保留。
|
||||||
|
type SpeechRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Input string `json:"input"`
|
||||||
|
Voice string `json:"voice,omitempty"`
|
||||||
|
ResponseFormat string `json:"response_format,omitempty"`
|
||||||
|
Language string `json:"language,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TtsRequest 描述 /ai/v1/tts 请求体的已知字段(xAI 官方 TTS 格式,文档用途)。
|
||||||
|
// model 为网关扩展字段(缺省 xai.grok-tts);未列字段原样透传上游。
|
||||||
|
type TtsRequest struct {
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
VoiceID string `json:"voice_id,omitempty"`
|
||||||
|
OutputFormat *TtsOutputFormat `json:"output_format,omitempty"`
|
||||||
|
Speed float64 `json:"speed,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TtsOutputFormat 是 xAI TTS 输出格式配置(缺省 MP3 24kHz/128kbps)。
|
||||||
|
type TtsOutputFormat struct {
|
||||||
|
Codec string `json:"codec,omitempty"`
|
||||||
|
SampleRate int `json:"sample_rate,omitempty"`
|
||||||
|
BitRate int `json:"bit_rate,omitempty"`
|
||||||
|
}
|
||||||
+96
-123
@@ -1,6 +1,6 @@
|
|||||||
// Package aiwire 定义 AI 网关的线格式类型:OpenAI Chat Completions(兼作网关内部
|
// Package aiwire 定义 AI 网关的线格式类型:OpenAI Responses / Chat Completions /
|
||||||
// 中间表示 IR,因 OCI GENERIC 格式即其镜像)与 Anthropic Messages。零内部依赖,
|
// Anthropic Messages / Embeddings 与通用记账、错误、模型列表结构。上游统一为
|
||||||
// oci 层与 service 层共同引用。
|
// OCI OpenAI 兼容面。
|
||||||
package aiwire
|
package aiwire
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -8,28 +8,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ChatRequest 是 OpenAI /v1/chat/completions 请求体;TopK 为 OCI/Anthropic 扩展字段。
|
|
||||||
type ChatRequest struct {
|
|
||||||
Model string `json:"model"`
|
|
||||||
Messages []ChatMessage `json:"messages"`
|
|
||||||
MaxTokens *int `json:"max_tokens,omitempty"`
|
|
||||||
MaxCompletionTokens *int `json:"max_completion_tokens,omitempty"`
|
|
||||||
Temperature *float64 `json:"temperature,omitempty"`
|
|
||||||
TopP *float64 `json:"top_p,omitempty"`
|
|
||||||
TopK *int `json:"top_k,omitempty"`
|
|
||||||
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
|
|
||||||
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
|
|
||||||
Stop StringList `json:"stop,omitempty"`
|
|
||||||
Seed *int `json:"seed,omitempty"`
|
|
||||||
N *int `json:"n,omitempty"`
|
|
||||||
Stream bool `json:"stream,omitempty"`
|
|
||||||
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
|
|
||||||
Tools []Tool `json:"tools,omitempty"`
|
|
||||||
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
|
|
||||||
ResponseFormat *ResponseFormat `json:"response_format,omitempty"`
|
|
||||||
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// StringList 兼容 OpenAI stop 的 string 与 []string 两种线格式。
|
// StringList 兼容 OpenAI stop 的 string 与 []string 两种线格式。
|
||||||
type StringList []string
|
type StringList []string
|
||||||
|
|
||||||
@@ -50,7 +28,75 @@ func (s *StringList) UnmarshalJSON(b []byte) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreamOptions 控制流式末尾是否附带 usage 事件。
|
// Usage 是 token 用量。
|
||||||
|
type Usage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
// PromptTokensDetails 携带缓存命中细分;OCI 为隐式 prompt 缓存,只有读取计数
|
||||||
|
PromptTokensDetails *PromptTokensDetails `json:"prompt_tokens_details,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromptTokensDetails 是输入 token 细分(OpenAI prompt_tokens_details 同构)。
|
||||||
|
type PromptTokensDetails struct {
|
||||||
|
CachedTokens int `json:"cached_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CachedTokens 返回缓存命中读取的 token 数;无细分时为 0。
|
||||||
|
func (u *Usage) CachedTokens() int {
|
||||||
|
if u == nil || u.PromptTokensDetails == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return u.PromptTokensDetails.CachedTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model 是 /v1/models 列表项。
|
||||||
|
type Model struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
OwnedBy string `json:"owned_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelList 是 /v1/models 响应体。
|
||||||
|
type ModelList struct {
|
||||||
|
Object string `json:"object"`
|
||||||
|
Data []Model `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorBody 是 OpenAI 格式错误响应。
|
||||||
|
type ErrorBody struct {
|
||||||
|
Error ErrorDetail `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorDetail 是错误明细。
|
||||||
|
type ErrorDetail struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Chat Completions 线格式(/ai/v1/chat/completions,Tier 2 兼容层)----
|
||||||
|
|
||||||
|
// ChatRequest 是 OpenAI /v1/chat/completions 请求体;只声明网关读取的字段,
|
||||||
|
// 无对应物的字段(stop / seed / n / penalty 等)由 JSON 解码天然忽略。
|
||||||
|
type ChatRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []ChatMessage `json:"messages"`
|
||||||
|
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||||
|
MaxCompletionTokens *int `json:"max_completion_tokens,omitempty"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
TopP *float64 `json:"top_p,omitempty"`
|
||||||
|
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"`
|
||||||
|
Stream bool `json:"stream,omitempty"`
|
||||||
|
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
|
||||||
|
Tools []Tool `json:"tools,omitempty"`
|
||||||
|
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
|
||||||
|
ResponseFormat *ResponseFormat `json:"response_format,omitempty"`
|
||||||
|
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamOptions 控制流式末尾是否附带 usage 块。
|
||||||
type StreamOptions struct {
|
type StreamOptions struct {
|
||||||
IncludeUsage bool `json:"include_usage"`
|
IncludeUsage bool `json:"include_usage"`
|
||||||
}
|
}
|
||||||
@@ -59,48 +105,31 @@ type StreamOptions struct {
|
|||||||
type ChatMessage struct {
|
type ChatMessage struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content Content `json:"content"`
|
Content Content `json:"content"`
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content 兼容 string 与多模态块数组两种线格式;一期仅透传文本。
|
// Content 兼容 string 与多模态块数组两种线格式,序列化时保形。
|
||||||
type Content struct {
|
type Content struct {
|
||||||
// Text 在原始值为字符串时填充
|
Text string
|
||||||
Text string
|
Parts []ContentPart
|
||||||
// Parts 在原始值为数组时填充
|
IsArray bool
|
||||||
Parts []ContentPart
|
|
||||||
// isArray 记录原始形态,序列化时保形
|
|
||||||
isArray bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContentPart 是块数组中的一段;text 与 image_url 被网关处理,其余类型拒绝。
|
|
||||||
type ContentPart struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Text string `json:"text,omitempty"`
|
|
||||||
ImageURL *ImageURL `json:"image_url,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImageURL 是 image_url 块负载;url 为 data URI(base64)或公网地址,与 OCI ImageUrl 同构。
|
|
||||||
type ImageURL struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
Detail string `json:"detail,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Content) UnmarshalJSON(b []byte) error {
|
func (c *Content) UnmarshalJSON(b []byte) error {
|
||||||
trimmed := strings.TrimSpace(string(b))
|
t := strings.TrimSpace(string(b))
|
||||||
if trimmed == "null" {
|
if t == "null" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if len(trimmed) > 0 && trimmed[0] == '[' {
|
if strings.HasPrefix(t, "[") {
|
||||||
c.isArray = true
|
c.IsArray = true
|
||||||
return json.Unmarshal(b, &c.Parts)
|
return json.Unmarshal(b, &c.Parts)
|
||||||
}
|
}
|
||||||
return json.Unmarshal(b, &c.Text)
|
return json.Unmarshal(b, &c.Text)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Content) MarshalJSON() ([]byte, error) {
|
func (c Content) MarshalJSON() ([]byte, error) {
|
||||||
if c.isArray {
|
if c.IsArray {
|
||||||
return json.Marshal(c.Parts)
|
return json.Marshal(c.Parts)
|
||||||
}
|
}
|
||||||
return json.Marshal(c.Text)
|
return json.Marshal(c.Text)
|
||||||
@@ -111,14 +140,9 @@ func NewTextContent(text string) Content {
|
|||||||
return Content{Text: text}
|
return Content{Text: text}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPartsContent 构造块数组形态的内容(多模态消息用)。
|
// JoinText 拍平内容为纯文本(数组形态拼接 text 块)。
|
||||||
func NewPartsContent(parts []ContentPart) Content {
|
|
||||||
return Content{Parts: parts, isArray: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
// JoinText 返回全部文本内容(数组形态拼接 text 块)。
|
|
||||||
func (c Content) JoinText() string {
|
func (c Content) JoinText() string {
|
||||||
if !c.isArray {
|
if !c.IsArray {
|
||||||
return c.Text
|
return c.Text
|
||||||
}
|
}
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
@@ -130,23 +154,20 @@ func (c Content) JoinText() string {
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasUnsupported 报告是否含网关无法承接的内容块(text / image_url 之外的类型)。
|
// ContentPart 是块数组中的一段;text 与 image_url 被网关承接,其余类型拒绝。
|
||||||
func (c Content) HasUnsupported() bool {
|
type ContentPart struct {
|
||||||
for _, p := range c.Parts {
|
Type string `json:"type"`
|
||||||
switch p.Type {
|
Text string `json:"text,omitempty"`
|
||||||
case "", "text":
|
ImageURL *ImageURL `json:"image_url,omitempty"`
|
||||||
case "image_url":
|
|
||||||
if p.ImageURL == nil || p.ImageURL.URL == "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool 是 function 工具定义。
|
// ImageURL 是 image_url 块负载;url 为 data URI(base64)或公网地址。
|
||||||
|
type ImageURL struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool 是嵌套形态的 function 工具定义。
|
||||||
type Tool struct {
|
type Tool struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Function FunctionDef `json:"function"`
|
Function FunctionDef `json:"function"`
|
||||||
@@ -178,7 +199,7 @@ type ResponseFormat struct {
|
|||||||
JSONSchema json.RawMessage `json:"json_schema,omitempty"`
|
JSONSchema json.RawMessage `json:"json_schema,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChatResponse 是非流式响应体。
|
// ChatResponse 是非流式响应体(chat.completion)。
|
||||||
type ChatResponse struct {
|
type ChatResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Object string `json:"object"`
|
Object string `json:"object"`
|
||||||
@@ -195,28 +216,6 @@ type Choice struct {
|
|||||||
FinishReason string `json:"finish_reason"`
|
FinishReason string `json:"finish_reason"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usage 是 token 用量。
|
|
||||||
type Usage struct {
|
|
||||||
PromptTokens int `json:"prompt_tokens"`
|
|
||||||
CompletionTokens int `json:"completion_tokens"`
|
|
||||||
TotalTokens int `json:"total_tokens"`
|
|
||||||
// PromptTokensDetails 携带缓存命中细分;OCI 为隐式 prompt 缓存,只有读取计数
|
|
||||||
PromptTokensDetails *PromptTokensDetails `json:"prompt_tokens_details,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PromptTokensDetails 是输入 token 细分(OpenAI prompt_tokens_details 同构)。
|
|
||||||
type PromptTokensDetails struct {
|
|
||||||
CachedTokens int `json:"cached_tokens"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CachedTokens 返回缓存命中读取的 token 数;无细分时为 0。
|
|
||||||
func (u *Usage) CachedTokens() int {
|
|
||||||
if u == nil || u.PromptTokensDetails == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return u.PromptTokensDetails.CachedTokens
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChatChunk 是流式增量事件体(chat.completion.chunk)。
|
// ChatChunk 是流式增量事件体(chat.completion.chunk)。
|
||||||
type ChatChunk struct {
|
type ChatChunk struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -254,29 +253,3 @@ type FunctionCallDelta struct {
|
|||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Arguments string `json:"arguments,omitempty"`
|
Arguments string `json:"arguments,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Model 是 /v1/models 列表项。
|
|
||||||
type Model struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Object string `json:"object"`
|
|
||||||
Created int64 `json:"created"`
|
|
||||||
OwnedBy string `json:"owned_by"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ModelList 是 /v1/models 响应体。
|
|
||||||
type ModelList struct {
|
|
||||||
Object string `json:"object"`
|
|
||||||
Data []Model `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ErrorBody 是 OpenAI 格式错误响应。
|
|
||||||
type ErrorBody struct {
|
|
||||||
Error ErrorDetail `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ErrorDetail 是错误明细。
|
|
||||||
type ErrorDetail struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Code string `json:"code,omitempty"`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package aiwire
|
||||||
|
|
||||||
|
// RerankRequest 是 /ai/v1/rerank 请求体(Jina / Cohere 通行风格)。
|
||||||
|
type RerankRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Query string `json:"query"`
|
||||||
|
Documents []string `json:"documents"`
|
||||||
|
TopN *int `json:"top_n,omitempty"`
|
||||||
|
ReturnDocuments *bool `json:"return_documents,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RerankDocument 包装原文,仅在 return_documents 时返回。
|
||||||
|
type RerankDocument struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RerankResult 是一条重排结果:index 指向请求 documents 下标。
|
||||||
|
type RerankResult struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
RelevanceScore float64 `json:"relevance_score"`
|
||||||
|
Document *RerankDocument `json:"document,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RerankResponse 是 /ai/v1/rerank 响应体。
|
||||||
|
type RerankResponse struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Results []RerankResult `json:"results"`
|
||||||
|
}
|
||||||
@@ -156,39 +156,6 @@ type RespReasoning struct {
|
|||||||
Effort string `json:"effort,omitempty"`
|
Effort string `json:"effort,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Response 是 /responses 响应对象。
|
|
||||||
type Response struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Object string `json:"object"`
|
|
||||||
CreatedAt int64 `json:"created_at"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Model string `json:"model"`
|
|
||||||
Output []RespOutItem `json:"output"`
|
|
||||||
Usage *RespUsage `json:"usage,omitempty"`
|
|
||||||
Error *RespError `json:"error"`
|
|
||||||
IncompleteDetails *RespIncomplete `json:"incomplete_details"`
|
|
||||||
Store bool `json:"store"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RespOutItem 是 output 数组项:message 或 function_call。
|
|
||||||
type RespOutItem struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
Status string `json:"status,omitempty"`
|
|
||||||
Role string `json:"role,omitempty"`
|
|
||||||
Content []RespOutPart `json:"content,omitempty"`
|
|
||||||
CallID string `json:"call_id,omitempty"`
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
Arguments string `json:"arguments,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RespOutPart 是 message item 的内容部件(output_text)。
|
|
||||||
type RespOutPart struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
Annotations []any `json:"annotations"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RespUsage 是 Responses 口径的用量(input/output 命名)。
|
// RespUsage 是 Responses 口径的用量(input/output 命名)。
|
||||||
type RespUsage struct {
|
type RespUsage struct {
|
||||||
InputTokens int `json:"input_tokens"`
|
InputTokens int `json:"input_tokens"`
|
||||||
@@ -217,3 +184,16 @@ type RespError struct {
|
|||||||
type RespIncomplete struct {
|
type RespIncomplete struct {
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RespResponse 描述 /ai/v1/responses 非流式响应的常用顶层字段(文档用途)。
|
||||||
|
// 直通端点原样返回上游 JSON,未列字段(reasoning、incomplete_details 等)同样保留。
|
||||||
|
type RespResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Output json.RawMessage `json:"output"`
|
||||||
|
Usage *RespUsage `json:"usage,omitempty"`
|
||||||
|
Error *RespError `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|||||||
+56
-6
@@ -1,8 +1,11 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -12,20 +15,67 @@ import (
|
|||||||
var (
|
var (
|
||||||
buildVersion = "dev"
|
buildVersion = "dev"
|
||||||
buildTime = ""
|
buildTime = ""
|
||||||
|
// startedAt 为进程启动时刻,运行时长与 CPU 均值的计算基准
|
||||||
|
startedAt = time.Now()
|
||||||
|
// aboutDB 由 main 启动时注入,存储指标据此定位数据库文件
|
||||||
|
aboutDB struct{ driver, path string }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SetAboutRuntime 注入数据库形态,「关于」页存储指标展示用;启动时调用一次。
|
||||||
|
func SetAboutRuntime(dbDriver, dbPath string) {
|
||||||
|
aboutDB.driver, aboutDB.path = dbDriver, dbPath
|
||||||
|
}
|
||||||
|
|
||||||
// about 返回构建与运行环境信息,「设置 · 关于」页展示。
|
// about 返回构建与运行环境信息,「设置 · 关于」页展示。
|
||||||
//
|
//
|
||||||
// @Summary 返回构建与运行环境信息,「设置 · 关于」页展示
|
// @Summary 返回构建、运行时长与资源占用信息,「设置 · 关于」页展示
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} aboutResponse
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/about [get]
|
// @Router /api/v1/about [get]
|
||||||
func about(c *gin.Context) {
|
func about(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"version": buildVersion,
|
"version": buildVersion,
|
||||||
"buildTime": buildTime,
|
"buildTime": buildTime,
|
||||||
"goVersion": runtime.Version(),
|
"goVersion": runtime.Version(),
|
||||||
"platform": runtime.GOOS + "/" + runtime.GOARCH,
|
"platform": runtime.GOOS + "/" + runtime.GOARCH,
|
||||||
|
"startedAt": startedAt.UTC().Format(time.RFC3339),
|
||||||
|
"uptimeSeconds": int64(time.Since(startedAt).Seconds()),
|
||||||
|
"resources": resourcesSnapshot(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resourcesSnapshot 采集进程资源占用:CPU 自启动均值、内存、goroutine 与存储。
|
||||||
|
func resourcesSnapshot() gin.H {
|
||||||
|
var ms runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&ms)
|
||||||
|
pct := 0.0
|
||||||
|
if up := time.Since(startedAt).Seconds(); up > 0 {
|
||||||
|
// 多核并行时均值可超 100%,口径为「占单核百分比」
|
||||||
|
pct = processCPUTime().Seconds() / up * 100
|
||||||
|
}
|
||||||
|
return gin.H{
|
||||||
|
"cpuAvgPercent": math.Round(pct*100) / 100,
|
||||||
|
"numCpu": runtime.NumCPU(),
|
||||||
|
"goroutines": runtime.NumGoroutine(),
|
||||||
|
"memHeapBytes": ms.HeapAlloc,
|
||||||
|
"memSysBytes": ms.Sys,
|
||||||
|
"dbEngine": aboutDB.driver,
|
||||||
|
"dbBytes": dbFileBytes(aboutDB.driver, aboutDB.path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dbFileBytes 统计数据库磁盘占用:sqlite 累加主文件与 -wal / -shm,
|
||||||
|
// 外部数据库(mysql / postgres)不可从本机度量,返回 -1 表示不可用。
|
||||||
|
func dbFileBytes(driver, path string) int64 {
|
||||||
|
if driver != "sqlite" || path == "" {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
for _, p := range []string{path, path + "-wal", path + "-shm"} {
|
||||||
|
if fi, err := os.Stat(p); err == nil {
|
||||||
|
total += fi.Size()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
//go:build !unix
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// processCPUTime 非 unix 平台无 getrusage,CPU 均值退化为 0。
|
||||||
|
func processCPUTime() time.Duration { return 0 }
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDBFileBytes(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(dir, "app.db")
|
||||||
|
mustWrite := func(p string, n int) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(p, make([]byte, n), 0o600); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mustWrite(dbPath, 100)
|
||||||
|
mustWrite(dbPath+"-wal", 30)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
driver string
|
||||||
|
path string
|
||||||
|
want int64
|
||||||
|
}{
|
||||||
|
{name: "sqlite 主文件加 wal", driver: "sqlite", path: dbPath, want: 130},
|
||||||
|
{name: "sqlite 文件不存在计 0", driver: "sqlite", path: filepath.Join(dir, "none.db"), want: 0},
|
||||||
|
{name: "外部数据库不可度量", driver: "postgres", path: "", want: -1},
|
||||||
|
{name: "路径为空不可度量", driver: "sqlite", path: "", want: -1},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := dbFileBytes(tc.driver, tc.path); got != tc.want {
|
||||||
|
t.Fatalf("dbFileBytes(%q, %q) = %d, want %d", tc.driver, tc.path, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResourcesSnapshot(t *testing.T) {
|
||||||
|
SetAboutRuntime("sqlite", filepath.Join(t.TempDir(), "absent.db"))
|
||||||
|
got := resourcesSnapshot()
|
||||||
|
|
||||||
|
if got["numCpu"].(int) < 1 || got["goroutines"].(int) < 1 {
|
||||||
|
t.Fatalf("numCpu / goroutines 应为正数: %+v", got)
|
||||||
|
}
|
||||||
|
if got["memHeapBytes"].(uint64) == 0 || got["memSysBytes"].(uint64) == 0 {
|
||||||
|
t.Fatalf("内存指标应为正数: %+v", got)
|
||||||
|
}
|
||||||
|
if got["cpuAvgPercent"].(float64) < 0 {
|
||||||
|
t.Fatalf("cpuAvgPercent 不应为负: %+v", got)
|
||||||
|
}
|
||||||
|
if got["dbEngine"].(string) != "sqlite" || got["dbBytes"].(int64) != 0 {
|
||||||
|
t.Fatalf("存储指标不符: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//go:build unix
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// processCPUTime 返回进程自启动累计的 CPU 时间(用户态 + 内核态);
|
||||||
|
// 取不到时返回 0,「关于」页 CPU 均值显示为 0 而非报错。
|
||||||
|
func processCPUTime() time.Duration {
|
||||||
|
var ru syscall.Rusage
|
||||||
|
if err := syscall.Getrusage(syscall.RUSAGE_SELF, &ru); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return time.Duration(ru.Utime.Nano() + ru.Stime.Nano())
|
||||||
|
}
|
||||||
+232
-18
@@ -3,9 +3,13 @@ package api
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
_ "oci-portal/internal/aiwire" // swagger 注解引用
|
||||||
|
_ "oci-portal/internal/model" // swagger 注解引用
|
||||||
|
|
||||||
"oci-portal/internal/service"
|
"oci-portal/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,7 +22,7 @@ type aiAdminHandler struct {
|
|||||||
|
|
||||||
// @Summary ---- 密钥 ----
|
// @Summary ---- 密钥 ----
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} itemsResponse[model.AiKey]
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-keys [get]
|
// @Router /api/v1/ai-keys [get]
|
||||||
func (h *aiAdminHandler) listKeys(c *gin.Context) {
|
func (h *aiAdminHandler) listKeys(c *gin.Context) {
|
||||||
@@ -35,20 +39,21 @@ func (h *aiAdminHandler) listKeys(c *gin.Context) {
|
|||||||
// @Summary 生成密钥
|
// @Summary 生成密钥
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Param body body object true "请求体(见接口说明)"
|
// @Param body body object true "请求体(见接口说明)"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} aiKeyCreateResponse "key 为明文密钥,仅本次返回"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-keys [post]
|
// @Router /api/v1/ai-keys [post]
|
||||||
func (h *aiAdminHandler) createKey(c *gin.Context) {
|
func (h *aiAdminHandler) createKey(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
Group string `json:"group"`
|
Group string `json:"group"`
|
||||||
|
Models []string `json:"models"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
plaintext, key, err := h.gw.CreateKey(c.Request.Context(), req.Name, req.Value, req.Group)
|
plaintext, key, err := h.gw.CreateKey(c.Request.Context(), req.Name, req.Value, req.Group, req.Models)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -69,15 +74,16 @@ func (h *aiAdminHandler) updateKey(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
Group *string `json:"group"`
|
Group *string `json:"group"`
|
||||||
|
Models *[]string `json:"models"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.gw.UpdateKey(c.Request.Context(), id, req.Name, req.Enabled, req.Group); err != nil {
|
if err := h.gw.UpdateKey(c.Request.Context(), id, req.Name, req.Enabled, req.Group, req.Models); err != nil {
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -108,7 +114,7 @@ func (h *aiAdminHandler) deleteKey(c *gin.Context) {
|
|||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param body body object true "请求体(见接口说明)"
|
// @Param body body object true "请求体(见接口说明)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} model.AiKey
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-keys/{id}/content-log [put]
|
// @Router /api/v1/ai-keys/{id}/content-log [put]
|
||||||
func (h *aiAdminHandler) updateKeyContentLog(c *gin.Context) {
|
func (h *aiAdminHandler) updateKeyContentLog(c *gin.Context) {
|
||||||
@@ -135,7 +141,7 @@ func (h *aiAdminHandler) updateKeyContentLog(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// @Summary 分页查询内容日志
|
// @Summary 分页查询内容日志
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} pagedResponse[model.AiCallLog]
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-content-logs [get]
|
// @Router /api/v1/ai-content-logs [get]
|
||||||
func (h *aiAdminHandler) listContentLogs(c *gin.Context) {
|
func (h *aiAdminHandler) listContentLogs(c *gin.Context) {
|
||||||
@@ -155,7 +161,7 @@ func (h *aiAdminHandler) listContentLogs(c *gin.Context) {
|
|||||||
|
|
||||||
// @Summary ---- 渠道 ----
|
// @Summary ---- 渠道 ----
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} itemsResponse[model.AiChannel]
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-channels [get]
|
// @Router /api/v1/ai-channels [get]
|
||||||
func (h *aiAdminHandler) listChannels(c *gin.Context) {
|
func (h *aiAdminHandler) listChannels(c *gin.Context) {
|
||||||
@@ -170,7 +176,7 @@ func (h *aiAdminHandler) listChannels(c *gin.Context) {
|
|||||||
// @Summary 创建 AI 渠道
|
// @Summary 创建 AI 渠道
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Param body body service.ChannelInput true "请求体"
|
// @Param body body service.ChannelInput true "请求体"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} model.AiChannel
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-channels [post]
|
// @Router /api/v1/ai-channels [post]
|
||||||
func (h *aiAdminHandler) createChannel(c *gin.Context) {
|
func (h *aiAdminHandler) createChannel(c *gin.Context) {
|
||||||
@@ -234,7 +240,7 @@ func (h *aiAdminHandler) deleteChannel(c *gin.Context) {
|
|||||||
// @Summary 触发探测:服务可见性 + 模型同步 + 配额试调,返回更新后的渠道
|
// @Summary 触发探测:服务可见性 + 模型同步 + 配额试调,返回更新后的渠道
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} model.AiChannel
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-channels/{id}/probe [post]
|
// @Router /api/v1/ai-channels/{id}/probe [post]
|
||||||
func (h *aiAdminHandler) probeChannel(c *gin.Context) {
|
func (h *aiAdminHandler) probeChannel(c *gin.Context) {
|
||||||
@@ -253,7 +259,7 @@ func (h *aiAdminHandler) probeChannel(c *gin.Context) {
|
|||||||
// @Summary 同步渠道模型缓存
|
// @Summary 同步渠道模型缓存
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} itemsResponse[model.AiModelCache]
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-channels/{id}/sync-models [post]
|
// @Router /api/v1/ai-channels/{id}/sync-models [post]
|
||||||
func (h *aiAdminHandler) syncChannelModels(c *gin.Context) {
|
func (h *aiAdminHandler) syncChannelModels(c *gin.Context) {
|
||||||
@@ -269,11 +275,60 @@ func (h *aiAdminHandler) syncChannelModels(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"items": models})
|
c.JSON(http.StatusOK, gin.H{"items": models})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @Summary 渠道模型缓存列表
|
||||||
|
// @Tags AI 管理
|
||||||
|
// @Param id path int true "渠道 ID"
|
||||||
|
// @Success 200 {object} itemsResponse[model.AiModelCache]
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/ai-channels/{id}/models [get]
|
||||||
|
func (h *aiAdminHandler) listChannelModels(c *gin.Context) {
|
||||||
|
id, ok := aiPathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
models, err := h.gw.ChannelModels(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": models})
|
||||||
|
}
|
||||||
|
|
||||||
|
type testChannelModelRequest struct {
|
||||||
|
Model string `json:"model" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary 测试渠道模型(max_tokens=16 试调;通过即设为探测验证模型并按需置渠道可用)
|
||||||
|
// @Tags AI 管理
|
||||||
|
// @Param id path int true "渠道 ID"
|
||||||
|
// @Param body body testChannelModelRequest true "模型名"
|
||||||
|
// @Success 200 {object} model.AiChannel
|
||||||
|
// @Failure 502 {object} errorResponse "试调未通过"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/ai-channels/{id}/test-model [post]
|
||||||
|
func (h *aiAdminHandler) testChannelModel(c *gin.Context) {
|
||||||
|
id, ok := aiPathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req testChannelModelRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch, err := h.gw.TestChannelModel(c.Request.Context(), id, req.Model)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, ch)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 聚合模型与调用日志 ----
|
// ---- 聚合模型与调用日志 ----
|
||||||
|
|
||||||
// @Summary ---- 聚合模型与调用日志 ----
|
// @Summary ---- 聚合模型与调用日志 ----
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} itemsResponse[aiwire.Model]
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-models [get]
|
// @Router /api/v1/ai-models [get]
|
||||||
func (h *aiAdminHandler) gatewayModels(c *gin.Context) {
|
func (h *aiAdminHandler) gatewayModels(c *gin.Context) {
|
||||||
@@ -285,9 +340,67 @@ func (h *aiAdminHandler) gatewayModels(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"items": list.Data})
|
c.JSON(http.StatusOK, gin.H{"items": list.Data})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 模型黑名单 ----
|
||||||
|
|
||||||
|
// @Summary 模型黑名单列表
|
||||||
|
// @Tags AI 管理
|
||||||
|
// @Success 200 {object} itemsResponse[model.AiModelBlacklist]
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/ai-blacklist [get]
|
||||||
|
func (h *aiAdminHandler) listBlacklist(c *gin.Context) {
|
||||||
|
items, err := h.gw.Blacklist(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// addBlacklist 拉黑模型:删除全部渠道缓存中的同名条目,后续同步 / 探测均过滤。
|
||||||
|
//
|
||||||
|
// @Summary 添加模型黑名单
|
||||||
|
// @Tags AI 管理
|
||||||
|
// @Param body body object true "请求体:{name: 模型名}"
|
||||||
|
// @Success 201 {object} itemResponse[model.AiModelBlacklist]
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/ai-blacklist [post]
|
||||||
|
func (h *aiAdminHandler) addBlacklist(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.gw.AddBlacklist(c.Request.Context(), req.Name)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"item": item})
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary 移除模型黑名单(重新同步后模型恢复入池)
|
||||||
|
// @Tags AI 管理
|
||||||
|
// @Param id path int true "黑名单条目 ID"
|
||||||
|
// @Success 204 "无内容"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/ai-blacklist/{id} [delete]
|
||||||
|
func (h *aiAdminHandler) removeBlacklist(c *gin.Context) {
|
||||||
|
id, ok := aiPathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.gw.RemoveBlacklist(c.Request.Context(), id); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
// @Summary AI 调用日志列表
|
// @Summary AI 调用日志列表
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} pagedResponse[model.AiContentLog]
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-logs [get]
|
// @Router /api/v1/ai-logs [get]
|
||||||
func (h *aiAdminHandler) listLogs(c *gin.Context) {
|
func (h *aiAdminHandler) listLogs(c *gin.Context) {
|
||||||
@@ -309,3 +422,104 @@ 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 网关全局设置(文档与响应共用)。
|
||||||
|
type aiSettingsResponse struct {
|
||||||
|
// 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 网关全局设置。
|
||||||
|
//
|
||||||
|
// @Summary AI 网关全局设置
|
||||||
|
// @Tags AI 管理
|
||||||
|
// @Success 200 {object} aiSettingsResponse
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/ai-settings [get]
|
||||||
|
func (h *aiAdminHandler) aiSettings(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, h.currentAiSettings())
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateAiSettings 更新 AI 网关全局设置(过滤弃用/流式保险丝/grok 搜索工具默认注入)。
|
||||||
|
//
|
||||||
|
// @Summary 更新 AI 网关全局设置
|
||||||
|
// @Tags AI 管理
|
||||||
|
// @Param body body aiSettingsResponse true "全量提交;保险丝阈值限 1..1024 KB,上游无响应预算限 30..900 秒"
|
||||||
|
// @Success 200 {object} aiSettingsResponse
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/ai-settings [put]
|
||||||
|
func (h *aiAdminHandler) updateAiSettings(c *gin.Context) {
|
||||||
|
var req aiSettingsResponse
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if 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)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|||||||
+462
-185
@@ -1,13 +1,16 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -86,6 +89,25 @@ func keyGroup(c *gin.Context) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// keyModels 取当前请求密钥的模型白名单(空 = 不限模型)。
|
||||||
|
func keyModels(c *gin.Context) []string {
|
||||||
|
if key, ok := c.Get(aiKeyCtx); ok {
|
||||||
|
return key.(*model.AiKey).Models
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkKeyModel 校验请求模型是否在密钥白名单内;拒绝时按端点协议返回
|
||||||
|
// 404 model_not_found(与未知模型同口径,不泄露密钥配置细节)并返回 false。
|
||||||
|
func checkKeyModel(c *gin.Context, modelName string) bool {
|
||||||
|
allowed := keyModels(c)
|
||||||
|
if len(allowed) == 0 || slices.Contains(allowed, modelName) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
aiError(c, http.StatusNotFound, "model_not_found", "模型不存在或无权访问: "+modelName)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// logEntry 组装调用日志骨架;usage / status 由调用方补齐。
|
// logEntry 组装调用日志骨架;usage / status 由调用方补齐。
|
||||||
func (h *aiGatewayHandler) logEntry(c *gin.Context, endpoint, modelName string, stream bool, meta service.ChatMeta, start time.Time) model.AiCallLog {
|
func (h *aiGatewayHandler) logEntry(c *gin.Context, endpoint, modelName string, stream bool, meta service.ChatMeta, start time.Time) model.AiCallLog {
|
||||||
entry := model.AiCallLog{
|
entry := model.AiCallLog{
|
||||||
@@ -102,21 +124,6 @@ func (h *aiGatewayHandler) logEntry(c *gin.Context, endpoint, modelName string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// validateIR 做协议无关的入参检查(模型名、消息、不支持的内容块)。
|
// validateIR 做协议无关的入参检查(模型名、消息、不支持的内容块)。
|
||||||
func validateIR(ir aiwire.ChatRequest) error {
|
|
||||||
if strings.TrimSpace(ir.Model) == "" {
|
|
||||||
return fmt.Errorf("model 不能为空")
|
|
||||||
}
|
|
||||||
if len(ir.Messages) == 0 {
|
|
||||||
return fmt.Errorf("messages 不能为空")
|
|
||||||
}
|
|
||||||
for _, m := range ir.Messages {
|
|
||||||
if m.Content.HasUnsupported() {
|
|
||||||
return service.ErrAiUnsupportedBlock
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// maybeLogContent 在调用密钥显式开启内容日志且未过期时写正文(红线例外);
|
// maybeLogContent 在调用密钥显式开启内容日志且未过期时写正文(红线例外);
|
||||||
// respBody 为 nil 时只记请求(流式响应与向量结果不记录);callLogID 关联同次调用日志。
|
// respBody 为 nil 时只记请求(流式响应与向量结果不记录);callLogID 关联同次调用日志。
|
||||||
func (h *aiGatewayHandler) maybeLogContent(c *gin.Context, callLogID uint, endpoint, modelName string, stream bool, reqBody, respBody any) {
|
func (h *aiGatewayHandler) maybeLogContent(c *gin.Context, callLogID uint, endpoint, modelName string, stream bool, reqBody, respBody any) {
|
||||||
@@ -147,47 +154,6 @@ func (h *aiGatewayHandler) logFailure(c *gin.Context, entry model.AiCallLog, req
|
|||||||
h.maybeLogContent(c, callID, entry.Endpoint, entry.Model, entry.Stream, reqBody, nil)
|
h.maybeLogContent(c, callID, entry.Endpoint, entry.Model, entry.Stream, reqBody, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// chatCompletions 是 OpenAI /ai/v1/chat/completions 端点。
|
|
||||||
//
|
|
||||||
// @Summary OpenAI 兼容对话补全
|
|
||||||
// @Tags AI 网关
|
|
||||||
// @Param body body object true "OpenAI chat/completions 请求体(支持 stream)"
|
|
||||||
// @Success 200 {object} map[string]any "OpenAI 兼容响应(流式为 SSE)"
|
|
||||||
// @Router /ai/v1/chat/completions [post]
|
|
||||||
func (h *aiGatewayHandler) chatCompletions(c *gin.Context) {
|
|
||||||
var ir aiwire.ChatRequest
|
|
||||||
if err := c.ShouldBindJSON(&ir); err != nil {
|
|
||||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := validateIR(ir); err != nil {
|
|
||||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if ir.Stream {
|
|
||||||
h.streamOpenAI(c, ir)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
start := time.Now()
|
|
||||||
resp, meta, err := h.gw.Chat(c.Request.Context(), ir, keyGroup(c))
|
|
||||||
entry := h.logEntry(c, "openai", ir.Model, false, meta, start)
|
|
||||||
if err != nil {
|
|
||||||
upstreamError(c, err)
|
|
||||||
entry.ErrMsg = err.Error()
|
|
||||||
h.logFailure(c, entry, ir)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resp.ID = aiRandID("chatcmpl-")
|
|
||||||
if resp.Created == 0 {
|
|
||||||
resp.Created = time.Now().Unix()
|
|
||||||
}
|
|
||||||
entry.Status = http.StatusOK
|
|
||||||
fillUsage(&entry, resp.Usage)
|
|
||||||
callID := h.gw.LogCall(entry)
|
|
||||||
h.maybeLogContent(c, callID, "openai", ir.Model, false, ir, resp)
|
|
||||||
c.JSON(http.StatusOK, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fillUsage(entry *model.AiCallLog, u *aiwire.Usage) {
|
func fillUsage(entry *model.AiCallLog, u *aiwire.Usage) {
|
||||||
if u == nil {
|
if u == nil {
|
||||||
return
|
return
|
||||||
@@ -200,8 +166,8 @@ func fillUsage(entry *model.AiCallLog, u *aiwire.Usage) {
|
|||||||
//
|
//
|
||||||
// @Summary OpenAI 兼容向量嵌入
|
// @Summary OpenAI 兼容向量嵌入
|
||||||
// @Tags AI 网关
|
// @Tags AI 网关
|
||||||
// @Param body body object true "OpenAI embeddings 请求体"
|
// @Param body body aiwire.EmbeddingsRequest true "OpenAI embeddings 请求体"
|
||||||
// @Success 200 {object} map[string]any "OpenAI 兼容响应"
|
// @Success 200 {object} aiwire.EmbeddingsResponse "OpenAI 兼容响应"
|
||||||
// @Router /ai/v1/embeddings [post]
|
// @Router /ai/v1/embeddings [post]
|
||||||
func (h *aiGatewayHandler) embeddings(c *gin.Context) {
|
func (h *aiGatewayHandler) embeddings(c *gin.Context) {
|
||||||
var req aiwire.EmbeddingsRequest
|
var req aiwire.EmbeddingsRequest
|
||||||
@@ -217,6 +183,9 @@ func (h *aiGatewayHandler) embeddings(c *gin.Context) {
|
|||||||
aiError(c, http.StatusBadRequest, "invalid_request_error", "仅支持 encoding_format=float")
|
aiError(c, http.StatusBadRequest, "invalid_request_error", "仅支持 encoding_format=float")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !checkKeyModel(c, req.Model) {
|
||||||
|
return
|
||||||
|
}
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
resp, meta, err := h.gw.Embeddings(c.Request.Context(), req, keyGroup(c))
|
resp, meta, err := h.gw.Embeddings(c.Request.Context(), req, keyGroup(c))
|
||||||
entry := h.logEntry(c, "embeddings", req.Model, false, meta, start)
|
entry := h.logEntry(c, "embeddings", req.Model, false, meta, start)
|
||||||
@@ -235,44 +204,6 @@ func (h *aiGatewayHandler) embeddings(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, resp)
|
c.JSON(http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// streamOpenAI 以 OpenAI SSE 直通流式响应,末尾发 [DONE]。
|
|
||||||
func (h *aiGatewayHandler) streamOpenAI(c *gin.Context, ir aiwire.ChatRequest) {
|
|
||||||
start := time.Now()
|
|
||||||
stream, meta, err := h.gw.OpenStream(c.Request.Context(), ir, keyGroup(c))
|
|
||||||
entry := h.logEntry(c, "openai", ir.Model, true, meta, start)
|
|
||||||
if err != nil {
|
|
||||||
upstreamError(c, err)
|
|
||||||
entry.ErrMsg = err.Error()
|
|
||||||
h.logFailure(c, entry, ir)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer stream.Close()
|
|
||||||
sseHeaders(c)
|
|
||||||
id, created := aiRandID("chatcmpl-"), time.Now().Unix()
|
|
||||||
var usage *aiwire.Usage
|
|
||||||
for {
|
|
||||||
chunk, err := stream.Next()
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, io.EOF) {
|
|
||||||
entry.ErrMsg = err.Error()
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
chunk.ID, chunk.Created = id, created
|
|
||||||
if chunk.Usage != nil {
|
|
||||||
usage = chunk.Usage
|
|
||||||
}
|
|
||||||
writeSSEData(c, chunk)
|
|
||||||
}
|
|
||||||
c.Writer.WriteString("data: [DONE]\n\n")
|
|
||||||
c.Writer.Flush()
|
|
||||||
entry.Status = http.StatusOK
|
|
||||||
entry.LatencyMs = time.Since(start).Milliseconds()
|
|
||||||
fillUsage(&entry, usage)
|
|
||||||
callID := h.gw.LogCall(entry)
|
|
||||||
h.maybeLogContent(c, callID, "openai", ir.Model, true, ir, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sseHeaders(c *gin.Context) {
|
func sseHeaders(c *gin.Context) {
|
||||||
c.Header("Content-Type", "text/event-stream")
|
c.Header("Content-Type", "text/event-stream")
|
||||||
c.Header("Cache-Control", "no-cache")
|
c.Header("Cache-Control", "no-cache")
|
||||||
@@ -280,23 +211,16 @@ func sseHeaders(c *gin.Context) {
|
|||||||
c.Writer.Flush()
|
c.Writer.Flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeSSEData(c *gin.Context, v any) {
|
// anthDefaultMaxTokens 是 max_tokens 缺省时的默认输出上限:Anthropic 协议
|
||||||
b, err := json.Marshal(v)
|
// 该字段必填,但部分客户端当选填不传,按默认值放行而非 400 拒绝。
|
||||||
if err != nil {
|
const anthDefaultMaxTokens = 8192
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Writer.WriteString("data: ")
|
|
||||||
c.Writer.Write(b)
|
|
||||||
c.Writer.WriteString("\n\n")
|
|
||||||
c.Writer.Flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
// messages 是 Anthropic /ai/v1/messages 端点。
|
// messages 是 Anthropic /ai/v1/messages 端点。
|
||||||
//
|
//
|
||||||
// @Summary Anthropic Messages 兼容端点
|
// @Summary Anthropic Messages 兼容端点
|
||||||
// @Tags AI 网关
|
// @Tags AI 网关
|
||||||
// @Param body body object true "Anthropic messages 请求体(支持 stream)"
|
// @Param body body aiwire.MessagesRequest true "Anthropic messages 请求体(支持 stream;经 OCI OpenAI 兼容面直通;max_tokens 可缺省,默认 8192)"
|
||||||
// @Success 200 {object} map[string]any "Anthropic 兼容响应(流式为 SSE)"
|
// @Success 200 {object} aiwire.MessagesResponse "Anthropic 兼容响应(非流式;流式为 SSE 事件序列)"
|
||||||
// @Router /ai/v1/messages [post]
|
// @Router /ai/v1/messages [post]
|
||||||
func (h *aiGatewayHandler) messages(c *gin.Context) {
|
func (h *aiGatewayHandler) messages(c *gin.Context) {
|
||||||
var req aiwire.MessagesRequest
|
var req aiwire.MessagesRequest
|
||||||
@@ -305,72 +229,139 @@ func (h *aiGatewayHandler) messages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.MaxTokens <= 0 {
|
if req.MaxTokens <= 0 {
|
||||||
aiError(c, http.StatusBadRequest, "invalid_request_error", "max_tokens 必填且需大于 0")
|
req.MaxTokens = anthDefaultMaxTokens
|
||||||
|
}
|
||||||
|
if len(req.Messages) == 0 {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", "messages 不能为空")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ir, err := service.AnthropicToIR(req)
|
if !checkKeyModel(c, req.Model) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := service.AnthropicToResponsesBody(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := validateIR(ir); err != nil {
|
|
||||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Stream {
|
if req.Stream {
|
||||||
h.streamAnthropic(c, ir)
|
h.streamAnthropic(c, body, req)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
resp, meta, err := h.gw.Chat(c.Request.Context(), ir, keyGroup(c))
|
payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), body, req.Model, keyGroup(c))
|
||||||
entry := h.logEntry(c, "anthropic", ir.Model, false, meta, start)
|
entry := h.logEntry(c, "anthropic", req.Model, false, meta, start)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
upstreamError(c, err)
|
upstreamError(c, err)
|
||||||
entry.ErrMsg = err.Error()
|
entry.ErrMsg = err.Error()
|
||||||
h.logFailure(c, entry, req)
|
h.logFailure(c, entry, req)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
out, err := service.ResponsesToAnthropic(payload, aiRandID("msg_"))
|
||||||
|
if err != nil {
|
||||||
|
aiError(c, http.StatusBadGateway, "api_error", err.Error())
|
||||||
|
entry.ErrMsg = err.Error()
|
||||||
|
h.logFailure(c, entry, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
entry.Status = http.StatusOK
|
entry.Status = http.StatusOK
|
||||||
fillUsage(&entry, resp.Usage)
|
fillUsage(&entry, service.RespPassthroughUsage(payload))
|
||||||
callID := h.gw.LogCall(entry)
|
callID := h.gw.LogCall(entry)
|
||||||
out := service.IRRespToAnthropic(resp, aiRandID("msg_"))
|
h.maybeLogContent(c, callID, "anthropic", req.Model, false, req, out)
|
||||||
h.maybeLogContent(c, callID, "anthropic", ir.Model, false, req, out)
|
|
||||||
c.JSON(http.StatusOK, out)
|
c.JSON(http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// streamAnthropic 经状态机把 IR chunk 流聚合为 Anthropic SSE 事件流。
|
// streamAnthropic 流式直通并桥接:上游 Responses SSE 事件经 AnthRespBridge
|
||||||
func (h *aiGatewayHandler) streamAnthropic(c *gin.Context, ir aiwire.ChatRequest) {
|
// 转为 Anthropic 事件序列逐块写出;usage 从 completed 事件记账。
|
||||||
|
func (h *aiGatewayHandler) streamAnthropic(c *gin.Context, body []byte, req aiwire.MessagesRequest) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
stream, meta, err := h.gw.OpenStream(c.Request.Context(), ir, keyGroup(c))
|
upstream, meta, err := h.gw.RespPassthroughStream(c.Request.Context(), body, req.Model, keyGroup(c))
|
||||||
entry := h.logEntry(c, "anthropic", ir.Model, true, meta, start)
|
entry := h.logEntry(c, "anthropic", req.Model, true, meta, start)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
upstreamError(c, err)
|
upstreamError(c, err)
|
||||||
entry.ErrMsg = err.Error()
|
entry.ErrMsg = err.Error()
|
||||||
h.logFailure(c, entry, ir)
|
h.logFailure(c, entry, req)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer stream.Close()
|
defer upstream.Close()
|
||||||
sseHeaders(c)
|
sseHeaders(c)
|
||||||
st := service.NewAnthStream(aiRandID("msg_"), ir.Model)
|
bridge := service.NewAnthRespBridge(aiRandID("msg_"), req.Model)
|
||||||
for {
|
emitted := 0
|
||||||
chunk, err := stream.Next()
|
if err := forwardSSEData(upstream, func(data []byte) {
|
||||||
if err != nil {
|
evs := bridge.Feed(data)
|
||||||
if !errors.Is(err, io.EOF) {
|
emitted += len(evs)
|
||||||
entry.ErrMsg = err.Error()
|
writeAnthEvents(c, evs)
|
||||||
}
|
}); err != nil {
|
||||||
break
|
entry.ErrMsg = err.Error()
|
||||||
|
}
|
||||||
|
// 上游断流且客户端尚未收到任何事件(OCI 兼容面对大请求 + 推理模型的
|
||||||
|
// 流式通道会在 reasoning 阶段掐断):降级非流式重做,结果按事件序列推送
|
||||||
|
if emitted == 0 && !bridge.SawTerminal() && c.Request.Context().Err() == nil {
|
||||||
|
if h.anthFallback(c, &entry, req) {
|
||||||
|
entry.Status = http.StatusOK
|
||||||
|
entry.LatencyMs = time.Since(start).Milliseconds()
|
||||||
|
callID := h.gw.LogCall(entry)
|
||||||
|
h.maybeLogContent(c, callID, "anthropic", req.Model, true, req, nil)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
writeAnthEvents(c, st.Feed(chunk))
|
}
|
||||||
|
writeAnthEvents(c, bridge.Finish())
|
||||||
|
// 上游错误事件 / 提前终止:SSE 头已发出维持 200,错误落日志可查
|
||||||
|
if msg := bridge.Err(); msg != "" && entry.ErrMsg == "" {
|
||||||
|
entry.ErrMsg = msg
|
||||||
}
|
}
|
||||||
writeAnthEvents(c, st.Finish())
|
|
||||||
entry.Status = http.StatusOK
|
entry.Status = http.StatusOK
|
||||||
entry.LatencyMs = time.Since(start).Milliseconds()
|
entry.LatencyMs = time.Since(start).Milliseconds()
|
||||||
usage := st.Usage()
|
usage := bridge.Usage()
|
||||||
entry.PromptTokens, entry.CompletionTokens = usage.InputTokens, usage.OutputTokens
|
entry.PromptTokens, entry.CompletionTokens = usage.InputTokens, usage.OutputTokens
|
||||||
entry.TotalTokens = usage.InputTokens + usage.OutputTokens
|
entry.TotalTokens = usage.InputTokens + usage.OutputTokens
|
||||||
entry.CachedTokens = usage.CacheReadInputTokens
|
entry.CachedTokens = usage.CacheReadInputTokens
|
||||||
callID := h.gw.LogCall(entry)
|
callID := h.gw.LogCall(entry)
|
||||||
h.maybeLogContent(c, callID, "anthropic", ir.Model, true, ir, nil)
|
h.maybeLogContent(c, callID, "anthropic", req.Model, true, req, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// anthFallback 用非流式重做同一请求并把完整结果按事件序列推送;
|
||||||
|
// 成功返回 true 并把渠道 / 用量 / 降级标注写入日志条目。
|
||||||
|
func (h *aiGatewayHandler) anthFallback(c *gin.Context, entry *model.AiCallLog, req aiwire.MessagesRequest) bool {
|
||||||
|
req.Stream = false
|
||||||
|
body, err := service.AnthropicToResponsesBody(req)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), body, req.Model, keyGroup(c))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
out, err := service.ResponsesToAnthropic(payload, aiRandID("msg_"))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
writeAnthEvents(c, service.AnthMessageEvents(out))
|
||||||
|
entry.ChannelID, entry.ChannelName = meta.ChannelID, meta.ChannelName
|
||||||
|
entry.Retries++
|
||||||
|
entry.ErrMsg = "流式上游断流,已降级非流式完成"
|
||||||
|
fillUsage(entry, service.RespPassthroughUsage(payload))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardSSEData 逐行读上游 SSE,把 data 行交给 emit 即时转换写出;
|
||||||
|
// Anthropic 与 Chat Completions 两条流式桥共用。
|
||||||
|
func forwardSSEData(upstream io.Reader, emit func([]byte)) error {
|
||||||
|
reader := bufio.NewReader(upstream)
|
||||||
|
for {
|
||||||
|
line, err := reader.ReadBytes('\n')
|
||||||
|
if len(line) > 0 {
|
||||||
|
trimmed := bytes.TrimSpace(line)
|
||||||
|
if data, ok := bytes.CutPrefix(trimmed, []byte("data: ")); ok {
|
||||||
|
emit(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeAnthEvents(c *gin.Context, events []service.AnthEvent) {
|
func writeAnthEvents(c *gin.Context, events []service.AnthEvent) {
|
||||||
@@ -386,11 +377,159 @@ func writeAnthEvents(c *gin.Context, events []service.AnthEvent) {
|
|||||||
c.Writer.Flush()
|
c.Writer.Flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// chatCompletions 是 OpenAI /ai/v1/chat/completions 端点(Tier 2 兼容层,
|
||||||
|
// 承接只会说 Chat Completions 的存量客户端)。
|
||||||
|
//
|
||||||
|
// @Summary OpenAI Chat Completions 兼容端点
|
||||||
|
// @Tags AI 网关
|
||||||
|
// @Param body body aiwire.ChatRequest true "OpenAI chat/completions 请求体(支持 stream;经 Responses 转换直通)"
|
||||||
|
// @Success 200 {object} aiwire.ChatResponse "OpenAI 兼容响应(非流式;流式为 SSE,末尾 data: [DONE])"
|
||||||
|
// @Router /ai/v1/chat/completions [post]
|
||||||
|
func (h *aiGatewayHandler) chatCompletions(c *gin.Context) {
|
||||||
|
var req aiwire.ChatRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Model) == "" || len(req.Messages) == 0 {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", "model 与 messages 不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !checkKeyModel(c, req.Model) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := service.ChatToResponsesBody(req)
|
||||||
|
if err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Stream {
|
||||||
|
h.streamChat(c, body, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.chatOnce(c, body, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatOnce 处理非流式:直通上游 → 转回 chat.completion → 记账。
|
||||||
|
func (h *aiGatewayHandler) chatOnce(c *gin.Context, body []byte, req aiwire.ChatRequest) {
|
||||||
|
start := time.Now()
|
||||||
|
payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), body, req.Model, keyGroup(c))
|
||||||
|
entry := h.logEntry(c, "openai", req.Model, false, meta, start)
|
||||||
|
if err != nil {
|
||||||
|
upstreamError(c, err)
|
||||||
|
entry.ErrMsg = err.Error()
|
||||||
|
h.logFailure(c, entry, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := service.ResponsesToChat(payload, aiRandID("chatcmpl-"), time.Now().Unix())
|
||||||
|
if err != nil {
|
||||||
|
aiError(c, http.StatusBadGateway, "api_error", err.Error())
|
||||||
|
entry.ErrMsg = err.Error()
|
||||||
|
h.logFailure(c, entry, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry.Status = http.StatusOK
|
||||||
|
fillUsage(&entry, out.Usage)
|
||||||
|
callID := h.gw.LogCall(entry)
|
||||||
|
h.maybeLogContent(c, callID, "openai", req.Model, false, req, out)
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamChat 流式直通并桥接:上游 Responses SSE 经 ChatRespBridge 转为
|
||||||
|
// chat.completion.chunk 序列逐块写出,末尾发 [DONE];usage 从 completed 事件记账。
|
||||||
|
func (h *aiGatewayHandler) streamChat(c *gin.Context, body []byte, req aiwire.ChatRequest) {
|
||||||
|
start := time.Now()
|
||||||
|
upstream, meta, err := h.gw.RespPassthroughStream(c.Request.Context(), body, req.Model, keyGroup(c))
|
||||||
|
entry := h.logEntry(c, "openai", req.Model, true, meta, start)
|
||||||
|
if err != nil {
|
||||||
|
upstreamError(c, err)
|
||||||
|
entry.ErrMsg = err.Error()
|
||||||
|
h.logFailure(c, entry, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer upstream.Close()
|
||||||
|
sseHeaders(c)
|
||||||
|
includeUsage := req.StreamOptions != nil && req.StreamOptions.IncludeUsage
|
||||||
|
bridge := service.NewChatRespBridge(aiRandID("chatcmpl-"), req.Model, time.Now().Unix(), includeUsage)
|
||||||
|
emitted := 0
|
||||||
|
if err := forwardSSEData(upstream, func(data []byte) {
|
||||||
|
chunks := bridge.Feed(data)
|
||||||
|
emitted += len(chunks)
|
||||||
|
writeChatChunks(c, chunks)
|
||||||
|
}); err != nil {
|
||||||
|
entry.ErrMsg = err.Error()
|
||||||
|
}
|
||||||
|
// 上游断流且客户端尚未收到任何块:降级非流式重做(成因同 messages 端点)
|
||||||
|
if emitted == 0 && bridge.Usage() == nil && c.Request.Context().Err() == nil {
|
||||||
|
if h.chatFallback(c, &entry, req, includeUsage) {
|
||||||
|
entry.Status = http.StatusOK
|
||||||
|
entry.LatencyMs = time.Since(start).Milliseconds()
|
||||||
|
callID := h.gw.LogCall(entry)
|
||||||
|
h.maybeLogContent(c, callID, "openai", req.Model, true, req, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeChatChunks(c, bridge.Finish())
|
||||||
|
c.Writer.WriteString("data: [DONE]\n\n")
|
||||||
|
c.Writer.Flush()
|
||||||
|
// 未见 completed 即结束:usage 缺失说明上游流异常提前终止,落日志可查
|
||||||
|
if bridge.Usage() == nil && entry.ErrMsg == "" {
|
||||||
|
entry.ErrMsg = "上游流提前终止,未返回终态事件"
|
||||||
|
}
|
||||||
|
entry.Status = http.StatusOK
|
||||||
|
entry.LatencyMs = time.Since(start).Milliseconds()
|
||||||
|
fillUsage(&entry, bridge.Usage())
|
||||||
|
callID := h.gw.LogCall(entry)
|
||||||
|
h.maybeLogContent(c, callID, "openai", req.Model, true, req, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatFallback 用非流式重做同一请求并把完整结果按 chunk 序列推送;
|
||||||
|
// 成功返回 true 并把渠道 / 用量 / 降级标注写入日志条目。
|
||||||
|
func (h *aiGatewayHandler) chatFallback(c *gin.Context, entry *model.AiCallLog, req aiwire.ChatRequest, includeUsage bool) bool {
|
||||||
|
req.Stream = false
|
||||||
|
body, err := service.ChatToResponsesBody(req)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), body, req.Model, keyGroup(c))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
out, err := service.ResponsesToChat(payload, aiRandID("chatcmpl-"), time.Now().Unix())
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
writeChatChunks(c, service.ChatResponseChunks(out, includeUsage))
|
||||||
|
c.Writer.WriteString("data: [DONE]\n\n")
|
||||||
|
c.Writer.Flush()
|
||||||
|
entry.ChannelID, entry.ChannelName = meta.ChannelID, meta.ChannelName
|
||||||
|
entry.Retries++
|
||||||
|
entry.ErrMsg = "流式上游断流,已降级非流式完成"
|
||||||
|
fillUsage(entry, service.RespPassthroughUsage(payload))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeChatChunks 逐块写出 chunk 的 SSE data 行并 flush。
|
||||||
|
func writeChatChunks(c *gin.Context, chunks []aiwire.ChatChunk) {
|
||||||
|
for _, ch := range chunks {
|
||||||
|
b, err := json.Marshal(ch)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.Writer.WriteString("data: ")
|
||||||
|
c.Writer.Write(b)
|
||||||
|
c.Writer.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
if len(chunks) > 0 {
|
||||||
|
c.Writer.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// listModels 是 /ai/v1/models 端点(OpenAI 格式,从启用渠道的模型缓存聚合)。
|
// listModels 是 /ai/v1/models 端点(OpenAI 格式,从启用渠道的模型缓存聚合)。
|
||||||
//
|
//
|
||||||
// @Summary 可用模型列表
|
// @Summary 可用模型列表
|
||||||
// @Tags AI 网关
|
// @Tags AI 网关
|
||||||
// @Success 200 {object} map[string]any "OpenAI 兼容 models 列表"
|
// @Success 200 {object} aiwire.ModelList "OpenAI 兼容 models 列表"
|
||||||
// @Router /ai/v1/models [get]
|
// @Router /ai/v1/models [get]
|
||||||
func (h *aiGatewayHandler) listModels(c *gin.Context) {
|
func (h *aiGatewayHandler) listModels(c *gin.Context) {
|
||||||
list, err := h.gw.GatewayModels(c.Request.Context(), keyGroup(c))
|
list, err := h.gw.GatewayModels(c.Request.Context(), keyGroup(c))
|
||||||
@@ -398,6 +537,15 @@ func (h *aiGatewayHandler) listModels(c *gin.Context) {
|
|||||||
aiError(c, http.StatusInternalServerError, "api_error", "查询模型列表失败")
|
aiError(c, http.StatusInternalServerError, "api_error", "查询模型列表失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if allowed := keyModels(c); len(allowed) > 0 {
|
||||||
|
kept := make([]aiwire.Model, 0, len(list.Data))
|
||||||
|
for _, m := range list.Data {
|
||||||
|
if slices.Contains(allowed, m.ID) {
|
||||||
|
kept = append(kept, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list.Data = kept
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, list)
|
c.JSON(http.StatusOK, list)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,86 +553,215 @@ func (h *aiGatewayHandler) listModels(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// @Summary OpenAI Responses 兼容端点
|
// @Summary OpenAI Responses 兼容端点
|
||||||
// @Tags AI 网关
|
// @Tags AI 网关
|
||||||
// @Param body body object true "OpenAI responses 请求体(支持 stream)"
|
// @Param body body aiwire.RespRequest true "OpenAI responses 请求体(支持 stream;服务端工具 web_search/x_search/code_interpreter/mcp 含流式;codex 兼容:namespace 工具组拍平为限定名 function 并在响应还原,custom 工具转 function 包装并回转 custom_tool_call(apply_patch 丢弃),tool_search 剥离,web_search.external_web_access 上游不支持自动处理,超 76KB 流式请求自动改非流式合成 SSE;未列字段原样透传上游)"
|
||||||
// @Success 200 {object} map[string]any "OpenAI 兼容响应(流式为 SSE)"
|
// @Success 200 {object} aiwire.RespResponse "OpenAI 兼容响应(非流式;流式为 SSE);直通仅建模常用字段,未列字段原样返回"
|
||||||
// @Router /ai/v1/responses [post]
|
// @Router /ai/v1/responses [post]
|
||||||
func (h *aiGatewayHandler) responses(c *gin.Context) {
|
func (h *aiGatewayHandler) responses(c *gin.Context) {
|
||||||
var req aiwire.RespRequest
|
raw, err := c.GetRawData()
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ir, err := service.ResponsesToIR(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := validateIR(ir); err != nil {
|
var req aiwire.RespRequest
|
||||||
|
if err := json.Unmarshal(raw, &req); err != nil {
|
||||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !checkKeyModel(c, req.Model) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.responsesPassthrough(c, raw, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// responsesPassthrough 直通链路(唯一上游):原始 body 改写后直达
|
||||||
|
// OCI /actions/v1/responses,响应原样透传。
|
||||||
|
func (h *aiGatewayHandler) responsesPassthrough(c *gin.Context, raw []byte, req aiwire.RespRequest) {
|
||||||
|
if err := service.RespPassthroughValidate(req); err != nil {
|
||||||
|
log.Printf("responses 直通(model=%s): 校验拒绝: %v", req.Model, err)
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, compat, err := service.RespPassthroughBody(raw)
|
||||||
|
if err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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 {
|
||||||
h.streamResponses(c, ir)
|
if on, kb := h.gw.StreamGuard(); on && service.RespGuardBytes(body) > kb*1024 {
|
||||||
|
h.responsesStreamUpgrade(c, body, req, compat)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.responsesPassthroughStream(c, body, req, compat)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
resp, meta, err := h.gw.Chat(c.Request.Context(), ir, keyGroup(c))
|
payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), body, req.Model, keyGroup(c))
|
||||||
entry := h.logEntry(c, "responses", ir.Model, false, meta, start)
|
entry := h.logEntry(c, "responses", req.Model, false, meta, start)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
upstreamError(c, err)
|
upstreamError(c, err)
|
||||||
entry.ErrMsg = err.Error()
|
entry.ErrMsg = err.Error()
|
||||||
h.logFailure(c, entry, req)
|
h.logFailure(c, entry, req)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
payload = service.RespRestoreToolCalls(payload, compat)
|
||||||
entry.Status = http.StatusOK
|
entry.Status = http.StatusOK
|
||||||
fillUsage(&entry, resp.Usage)
|
fillUsage(&entry, service.RespPassthroughUsage(payload))
|
||||||
callID := h.gw.LogCall(entry)
|
callID := h.gw.LogCall(entry)
|
||||||
out := service.IRRespToResponses(resp, aiRandID("resp_"), time.Now().Unix())
|
h.maybeLogContent(c, callID, "responses", req.Model, false, req, json.RawMessage(payload))
|
||||||
h.maybeLogContent(c, callID, "responses", ir.Model, false, req, out)
|
c.Data(http.StatusOK, "application/json; charset=utf-8", payload)
|
||||||
c.JSON(http.StatusOK, out)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// streamResponses 经状态机把 IR chunk 流聚合为 Responses 语义事件流。
|
// logRespCompat 记录直通请求的 codex 兼容改写动作(观测)。
|
||||||
func (h *aiGatewayHandler) streamResponses(c *gin.Context, ir aiwire.ChatRequest) {
|
func logRespCompat(model string, compat service.RespCompat) {
|
||||||
|
if len(compat.Flattened) == 0 && len(compat.Dropped) == 0 && len(compat.Converted) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
if len(compat.Flattened) > 0 {
|
||||||
|
parts = append(parts, "拍平: "+strings.Join(compat.Flattened, ", "))
|
||||||
|
}
|
||||||
|
if len(compat.Converted) > 0 {
|
||||||
|
parts = append(parts, "转换: "+strings.Join(compat.Converted, ", "))
|
||||||
|
}
|
||||||
|
if len(compat.Dropped) > 0 {
|
||||||
|
parts = append(parts, "剥离: "+strings.Join(compat.Dropped, ", "))
|
||||||
|
}
|
||||||
|
log.Printf("responses 直通(model=%s): %s", model, strings.Join(parts, "; "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// responsesStreamUpgrade 流式升级回退:instructions+tools 合计超过保险丝阈值
|
||||||
|
// (设置页 AI Tab 配置,默认开 60KB;上游对 >≈64.5KB 会静默断流)时改调非流式
|
||||||
|
// 上游拿完整响应,本地合成最小 SSE 事件序列回给客户端;丢失增量输出,换会话不中断。
|
||||||
|
func (h *aiGatewayHandler) responsesStreamUpgrade(c *gin.Context, body []byte, req aiwire.RespRequest, compat service.RespCompat) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
stream, meta, err := h.gw.OpenStream(c.Request.Context(), ir, keyGroup(c))
|
nsBody, err := service.RespDisableStream(body)
|
||||||
entry := h.logEntry(c, "responses", ir.Model, true, meta, start)
|
if err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
entry := h.logEntry(c, "responses", req.Model, true, meta, start)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
upstreamError(c, err)
|
upstreamError(c, err)
|
||||||
entry.ErrMsg = err.Error()
|
entry.ErrMsg = err.Error()
|
||||||
h.logFailure(c, entry, ir)
|
h.logFailure(c, entry, req)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer stream.Close()
|
payload = service.RespRestoreToolCalls(payload, compat)
|
||||||
sseHeaders(c)
|
writeSynthSSE(c, payload)
|
||||||
st := service.NewRespStream(aiRandID("resp_"), ir.Model, time.Now().Unix())
|
|
||||||
for {
|
|
||||||
chunk, err := stream.Next()
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, io.EOF) {
|
|
||||||
entry.ErrMsg = err.Error()
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
writeRespEvents(c, st.Feed(chunk))
|
|
||||||
}
|
|
||||||
writeRespEvents(c, st.Finish())
|
|
||||||
entry.Status = http.StatusOK
|
entry.Status = http.StatusOK
|
||||||
entry.LatencyMs = time.Since(start).Milliseconds()
|
entry.LatencyMs = time.Since(start).Milliseconds()
|
||||||
fillUsage(&entry, st.Usage())
|
fillUsage(&entry, service.RespPassthroughUsage(payload))
|
||||||
callID := h.gw.LogCall(entry)
|
callID := h.gw.LogCall(entry)
|
||||||
h.maybeLogContent(c, callID, "responses", ir.Model, true, ir, nil)
|
h.maybeLogContent(c, callID, "responses", req.Model, true, req, json.RawMessage(payload))
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeRespEvents(c *gin.Context, events []service.RespEvent) {
|
// writeSynthSSE 把完整响应按合成事件序列写出;合成失败时降级为一次性 JSON,
|
||||||
|
// 客户端至少拿到完整结果。
|
||||||
|
func writeSynthSSE(c *gin.Context, payload []byte) {
|
||||||
|
events, err := service.RespSynthSSEEvents(payload)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("responses 直通: 合成 SSE 失败,降级 JSON 返回: %v", err)
|
||||||
|
c.Data(http.StatusOK, "application/json; charset=utf-8", payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sseHeaders(c)
|
||||||
for _, ev := range events {
|
for _, ev := range events {
|
||||||
b, err := json.Marshal(ev.Data)
|
c.Writer.Write([]byte("data: "))
|
||||||
if err != nil {
|
c.Writer.Write(ev)
|
||||||
continue
|
c.Writer.Write([]byte("\n\n"))
|
||||||
}
|
|
||||||
c.Writer.WriteString("event: " + ev.Event + "\ndata: ")
|
|
||||||
c.Writer.Write(b)
|
|
||||||
c.Writer.WriteString("\n\n")
|
|
||||||
}
|
}
|
||||||
c.Writer.Flush()
|
c.Writer.Flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// responsesPassthroughStream 流式直通:SSE 事件原样转发(推理增量等直达客户端),
|
||||||
|
// 逐行扫描 completed 事件提取 usage 记账;refs 非空时对 function_call 事件做
|
||||||
|
// namespace 还原后再转发。
|
||||||
|
func (h *aiGatewayHandler) responsesPassthroughStream(c *gin.Context, body []byte, req aiwire.RespRequest, compat service.RespCompat) {
|
||||||
|
start := time.Now()
|
||||||
|
upstream, meta, err := h.gw.RespPassthroughStream(c.Request.Context(), body, req.Model, keyGroup(c))
|
||||||
|
entry := h.logEntry(c, "responses", req.Model, true, meta, start)
|
||||||
|
if err != nil {
|
||||||
|
upstreamError(c, err)
|
||||||
|
entry.ErrMsg = err.Error()
|
||||||
|
h.logFailure(c, entry, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer upstream.Close()
|
||||||
|
sseHeaders(c)
|
||||||
|
usage, upErr, err := forwardSSE(c, upstream, compat)
|
||||||
|
if err != nil {
|
||||||
|
entry.ErrMsg = err.Error()
|
||||||
|
} else if upErr != "" {
|
||||||
|
// 上游错误事件已原样转发给客户端,这里落日志可查
|
||||||
|
entry.ErrMsg = upErr
|
||||||
|
} else if usage == nil {
|
||||||
|
entry.ErrMsg = "上游流提前终止,未返回终态事件"
|
||||||
|
}
|
||||||
|
entry.Status = http.StatusOK
|
||||||
|
entry.LatencyMs = time.Since(start).Milliseconds()
|
||||||
|
fillUsage(&entry, usage)
|
||||||
|
callID := h.gw.LogCall(entry)
|
||||||
|
h.maybeLogContent(c, callID, "responses", req.Model, true, req, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardSSE 把上游 SSE 逐行转发给客户端,空行(事件边界)即 flush;
|
||||||
|
// 顺带从 data 行提取 response.completed 的 usage 与错误事件消息;
|
||||||
|
// refs 非空时 data 行先做 namespace 还原(未改动的行原样转发)。
|
||||||
|
func forwardSSE(c *gin.Context, upstream io.Reader, compat service.RespCompat) (*aiwire.Usage, string, error) {
|
||||||
|
reader := bufio.NewReader(upstream)
|
||||||
|
var usage *aiwire.Usage
|
||||||
|
var upErr string
|
||||||
|
for {
|
||||||
|
line, err := reader.ReadBytes('\n')
|
||||||
|
if len(line) > 0 {
|
||||||
|
trimmed := bytes.TrimSpace(line)
|
||||||
|
if data, ok := bytes.CutPrefix(trimmed, []byte("data: ")); ok {
|
||||||
|
c.Writer.Write(restoreSSELine(line, data, compat))
|
||||||
|
if u := service.RespStreamCompletedUsage(data); u != nil {
|
||||||
|
usage = u
|
||||||
|
}
|
||||||
|
if m := service.RespStreamErrorMsg(data); m != "" && upErr == "" {
|
||||||
|
upErr = m
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
c.Writer.Write(line)
|
||||||
|
if len(trimmed) == 0 {
|
||||||
|
c.Writer.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.Writer.Flush()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return usage, upErr, nil
|
||||||
|
}
|
||||||
|
return usage, upErr, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreSSELine 对一行 data 事件做工具调用项还原(namespace/custom),
|
||||||
|
// 未改动时原行透传(字节级直通)。
|
||||||
|
func restoreSSELine(line, data []byte, compat service.RespCompat) []byte {
|
||||||
|
if !compat.NeedRestore() {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
restored, changed := service.RespRestoreToolCallsEvent(data, compat)
|
||||||
|
if !changed {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
out := make([]byte, 0, len(restored)+8)
|
||||||
|
out = append(out, "data: "...)
|
||||||
|
out = append(out, restored...)
|
||||||
|
out = append(out, '\n')
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"oci-portal/internal/aiwire"
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// audioSpeech 是 OpenAI /ai/v1/audio/speech 端点(TTS,直通兼容面)。
|
||||||
|
//
|
||||||
|
// @Summary OpenAI Audio Speech 兼容端点(文本转语音)
|
||||||
|
// @Tags AI 网关
|
||||||
|
// @Param body body aiwire.SpeechRequest true "OpenAI audio speech 请求体(model/input 必填,voice 见 xAI Grok Voice 列表,language 缺省 auto;未列字段原样透传)"
|
||||||
|
// @Success 200 {file} binary "音频字节(Content-Type 透传上游,默认 audio/mpeg)"
|
||||||
|
// @Router /ai/v1/audio/speech [post]
|
||||||
|
func (h *aiGatewayHandler) audioSpeech(c *gin.Context) {
|
||||||
|
raw, err := c.GetRawData()
|
||||||
|
if err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modelName, body, err := service.SpeechBodyNormalize(raw)
|
||||||
|
if err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.speechRespond(c, "speech", modelName, raw, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tts 是 xAI 官方格式 TTS 端点(/ai/v1/tts),转换为上游 OpenAI 兼容形态后复用 Speech 编排。
|
||||||
|
//
|
||||||
|
// @Summary xAI 官方格式文本转语音端点
|
||||||
|
// @Tags AI 网关
|
||||||
|
// @Param body body aiwire.TtsRequest true "xAI TTS 请求体(text/language 必填,voice_id 缺省 eve;model 为网关扩展,缺省 xai.grok-tts;未列字段原样透传)"
|
||||||
|
// @Success 200 {file} binary "音频字节(Content-Type 透传上游,默认 audio/mpeg)"
|
||||||
|
// @Router /ai/v1/tts [post]
|
||||||
|
func (h *aiGatewayHandler) tts(c *gin.Context) {
|
||||||
|
raw, err := c.GetRawData()
|
||||||
|
if err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modelName, body, err := service.TtsBodyConvert(raw)
|
||||||
|
if err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.speechRespond(c, "tts", modelName, raw, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// speechRespond 是 audioSpeech / tts 的公共主体:白名单校验、上游调用、日志与音频响应。
|
||||||
|
func (h *aiGatewayHandler) speechRespond(c *gin.Context, endpoint, modelName string, raw, body []byte) {
|
||||||
|
if !checkKeyModel(c, modelName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
audio, contentType, meta, err := h.gw.Speech(c.Request.Context(), modelName, body, keyGroup(c))
|
||||||
|
entry := h.logEntry(c, endpoint, modelName, false, meta, start)
|
||||||
|
if err != nil {
|
||||||
|
upstreamError(c, err)
|
||||||
|
entry.ErrMsg = err.Error()
|
||||||
|
h.logFailure(c, entry, string(raw))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry.Status = http.StatusOK
|
||||||
|
callID := h.gw.LogCall(entry)
|
||||||
|
h.maybeLogContent(c, callID, endpoint, modelName, false, string(raw), nil)
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "audio/mpeg"
|
||||||
|
}
|
||||||
|
c.Data(http.StatusOK, contentType, audio)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rerank 是 /ai/v1/rerank 端点(Jina / Cohere 风格文档重排)。
|
||||||
|
//
|
||||||
|
// @Summary 文档重排端点(Cohere Rerank)
|
||||||
|
// @Tags AI 网关
|
||||||
|
// @Param body body aiwire.RerankRequest true "重排请求体(model/query/documents 必填,可选 top_n/return_documents)"
|
||||||
|
// @Success 200 {object} aiwire.RerankResponse "重排结果(results[].index 指向入参下标)"
|
||||||
|
// @Router /ai/v1/rerank [post]
|
||||||
|
func (h *aiGatewayHandler) rerank(c *gin.Context) {
|
||||||
|
var req aiwire.RerankRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Model) == "" || strings.TrimSpace(req.Query) == "" || len(req.Documents) == 0 {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", "model、query 与 documents 不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !checkKeyModel(c, req.Model) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
resp, meta, err := h.gw.Rerank(c.Request.Context(), req, keyGroup(c))
|
||||||
|
entry := h.logEntry(c, "rerank", req.Model, false, meta, start)
|
||||||
|
if err != nil {
|
||||||
|
upstreamError(c, err)
|
||||||
|
entry.ErrMsg = err.Error()
|
||||||
|
h.logFailure(c, entry, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry.Status = http.StatusOK
|
||||||
|
callID := h.gw.LogCall(entry)
|
||||||
|
h.maybeLogContent(c, callID, "rerank", req.Model, false, req, resp)
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// moderations 是 /ai/v1/moderations 端点(OpenAI 外壳映射 OCI Guardrails)。
|
||||||
|
//
|
||||||
|
// @Summary 内容审核端点(OCI Guardrails)
|
||||||
|
// @Tags AI 网关
|
||||||
|
// @Param body body aiwire.ModerationsRequest true "审核请求体(input 为字符串或字符串数组,单次至多 8 条;model 接受但忽略)"
|
||||||
|
// @Success 200 {object} aiwire.ModerationsResponse "审核结果(categories/category_scores 为 overall/blocklist/prompt_injection,pii 为扩展字段)"
|
||||||
|
// @Router /ai/v1/moderations [post]
|
||||||
|
func (h *aiGatewayHandler) moderations(c *gin.Context) {
|
||||||
|
var req aiwire.ModerationsRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inputs, err := service.ModerationInputs(req.Input)
|
||||||
|
if err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
resp, meta, err := h.gw.Moderations(c.Request.Context(), aiRandID("modr_"), inputs, keyGroup(c))
|
||||||
|
entry := h.logEntry(c, "moderations", "oci-guardrails", false, meta, start)
|
||||||
|
if err != nil {
|
||||||
|
upstreamError(c, err)
|
||||||
|
entry.ErrMsg = err.Error()
|
||||||
|
h.logFailure(c, entry, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry.Status = http.StatusOK
|
||||||
|
callID := h.gw.LogCall(entry)
|
||||||
|
h.maybeLogContent(c, callID, "moderations", "oci-guardrails", false, req, resp)
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedGatewayModel 直插启用渠道与模型缓存,绕过云同步。
|
||||||
|
func seedGatewayModel(t *testing.T, db *gorm.DB, name string) {
|
||||||
|
t.Helper()
|
||||||
|
ch := &model.AiChannel{Name: "t-" + name, OciConfigID: 1, Region: "r-" + name, Enabled: true, Priority: 1, Weight: 1}
|
||||||
|
if err := db.Create(ch).Error; err != nil {
|
||||||
|
t.Fatalf("seed channel: %v", err)
|
||||||
|
}
|
||||||
|
cache := &model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1.." + name, Name: name, Vendor: "v", SyncedAt: time.Now()}
|
||||||
|
if err := db.Create(cache).Error; err != nil {
|
||||||
|
t.Fatalf("seed model cache: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAiGatewayKeyModelRestrict(t *testing.T) {
|
||||||
|
r, auth, _, db := newTestRouterDB(t)
|
||||||
|
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("login: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 受限密钥:白名单含脏输入,入库应规范化为 2 项(cohere + ghost)
|
||||||
|
w := doRequest(t, r, http.MethodPost, "/api/v1/ai-keys", token,
|
||||||
|
`{"name":"limited","value":"limited-key-1234","models":[" cohere.command-a-03-2025 ","cohere.command-a-03-2025","","ghost-model"]}`)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("创建受限密钥 status = %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var created struct {
|
||||||
|
Item model.AiKey `json:"item"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(created.Item.Models) != 2 || created.Item.Models[0] != "cohere.command-a-03-2025" {
|
||||||
|
t.Fatalf("创建后 models 未规范化: %v", created.Item.Models)
|
||||||
|
}
|
||||||
|
// 不限密钥对照
|
||||||
|
w = doRequest(t, r, http.MethodPost, "/api/v1/ai-keys", token, `{"name":"open","value":"open-key-12345"}`)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("创建不限密钥 status = %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
seedGatewayModel(t, db, "cohere.command-a-03-2025")
|
||||||
|
seedGatewayModel(t, db, "meta.llama-3.3-70b-instruct")
|
||||||
|
|
||||||
|
deny := []string{"model_not_found", "无权访问"}
|
||||||
|
pass := []string{"未知模型"} // 穿过白名单闸门,由编排层以「未知模型」拒绝(池内无 ghost-model)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
key string
|
||||||
|
path string
|
||||||
|
body string
|
||||||
|
wantCode int
|
||||||
|
wantSub []string
|
||||||
|
}{
|
||||||
|
{"responses 流式同样拦截", "limited-key-1234", "/ai/v1/responses",
|
||||||
|
`{"model":"meta.llama-3.3-70b-instruct","stream":true,"input":"hi"}`, 404, deny},
|
||||||
|
{"responses 白名单内穿透", "limited-key-1234", "/ai/v1/responses",
|
||||||
|
`{"model":"ghost-model","input":"hi"}`, 404, pass},
|
||||||
|
{"embeddings 白名单外拦截", "limited-key-1234", "/ai/v1/embeddings",
|
||||||
|
`{"model":"meta.llama-3.3-70b-instruct","input":["hi"]}`, 404, deny},
|
||||||
|
{"messages 白名单外拦截为 Anthropic 体", "limited-key-1234", "/ai/v1/messages",
|
||||||
|
`{"model":"meta.llama-3.3-70b-instruct","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}`, 404,
|
||||||
|
[]string{`"type":"error"`, "model_not_found", "无权访问"}},
|
||||||
|
{"responses 白名单外拦截", "limited-key-1234", "/ai/v1/responses",
|
||||||
|
`{"model":"meta.llama-3.3-70b-instruct","input":"hi"}`, 404, deny},
|
||||||
|
{"不限密钥穿透闸门", "open-key-12345", "/ai/v1/responses",
|
||||||
|
`{"model":"ghost-model","input":"hi"}`, 404, pass},
|
||||||
|
{"chat completions 白名单外拦截", "limited-key-1234", "/ai/v1/chat/completions",
|
||||||
|
`{"model":"meta.llama-3.3-70b-instruct","messages":[{"role":"user","content":"hi"}]}`, 404, deny},
|
||||||
|
{"chat completions 白名单内穿透", "limited-key-1234", "/ai/v1/chat/completions",
|
||||||
|
`{"model":"ghost-model","messages":[{"role":"user","content":"hi"}]}`, 404, pass},
|
||||||
|
{"messages 缺 max_tokens 按默认值放行", "open-key-12345", "/ai/v1/messages",
|
||||||
|
`{"model":"ghost-model","messages":[{"role":"user","content":"hi"}]}`, 404, pass},
|
||||||
|
{"chat completions 缺 messages 拒绝", "open-key-12345", "/ai/v1/chat/completions",
|
||||||
|
`{"model":"ghost-model"}`, 400, []string{"invalid_request_error"}},
|
||||||
|
{"chat completions 非 function 工具拒绝", "open-key-12345", "/ai/v1/chat/completions",
|
||||||
|
`{"model":"ghost-model","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"web_search","function":{}}]}`,
|
||||||
|
400, []string{"仅支持 function"}},
|
||||||
|
{"chat completions 不支持内容块拒绝", "open-key-12345", "/ai/v1/chat/completions",
|
||||||
|
`{"model":"ghost-model","messages":[{"role":"user","content":[{"type":"input_audio"}]}]}`,
|
||||||
|
400, []string{"invalid_request_error"}},
|
||||||
|
{"audio speech 白名单外拦截", "limited-key-1234", "/ai/v1/audio/speech",
|
||||||
|
`{"model":"meta.llama-3.3-70b-instruct","input":"你好"}`, 404, deny},
|
||||||
|
{"rerank 白名单外拦截", "limited-key-1234", "/ai/v1/rerank",
|
||||||
|
`{"model":"meta.llama-3.3-70b-instruct","query":"q","documents":["d"]}`, 404, deny},
|
||||||
|
{"rerank 缺 documents 拒绝", "open-key-12345", "/ai/v1/rerank",
|
||||||
|
`{"model":"ghost-model","query":"q"}`, 400, []string{"invalid_request_error"}},
|
||||||
|
{"moderations 空 input 拒绝", "open-key-12345", "/ai/v1/moderations",
|
||||||
|
`{"input":[]}`, 400, []string{"invalid_request_error"}},
|
||||||
|
{"tts 缺 language 拒绝", "open-key-12345", "/ai/v1/tts",
|
||||||
|
`{"text":"你好"}`, 400, []string{"invalid_request_error"}},
|
||||||
|
{"tts 白名单外拦截", "limited-key-1234", "/ai/v1/tts",
|
||||||
|
`{"model":"meta.llama-3.3-70b-instruct","text":"你好","language":"zh"}`, 404, deny},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
w := doRequest(t, r, http.MethodPost, tt.path, tt.key, tt.body)
|
||||||
|
if w.Code != tt.wantCode {
|
||||||
|
t.Fatalf("status = %d, want %d, body %s", w.Code, tt.wantCode, w.Body.String())
|
||||||
|
}
|
||||||
|
for _, sub := range tt.wantSub {
|
||||||
|
if !strings.Contains(w.Body.String(), sub) {
|
||||||
|
t.Errorf("body 缺少 %q: %s", sub, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAiGatewayModelsFilteredByKey(t *testing.T) {
|
||||||
|
r, auth, _, db := newTestRouterDB(t)
|
||||||
|
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("login: %v", err)
|
||||||
|
}
|
||||||
|
seedGatewayModel(t, db, "cohere.command-a-03-2025")
|
||||||
|
seedGatewayModel(t, db, "meta.llama-3.3-70b-instruct")
|
||||||
|
w := doRequest(t, r, http.MethodPost, "/api/v1/ai-keys", token,
|
||||||
|
`{"name":"limited","value":"limited-key-1234","models":["cohere.command-a-03-2025"]}`)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("创建密钥 status = %d", w.Code)
|
||||||
|
}
|
||||||
|
w = doRequest(t, r, http.MethodPost, "/api/v1/ai-keys", token, `{"name":"open","value":"open-key-12345"}`)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("创建密钥 status = %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
key string
|
||||||
|
want []string
|
||||||
|
notWant []string
|
||||||
|
}{
|
||||||
|
{"受限密钥仅见交集", "limited-key-1234", []string{"cohere.command-a-03-2025"}, []string{"meta.llama-3.3-70b-instruct"}},
|
||||||
|
{"不限密钥全量可见", "open-key-12345", []string{"cohere.command-a-03-2025", "meta.llama-3.3-70b-instruct"}, nil},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
w := doRequest(t, r, http.MethodGet, "/ai/v1/models", tt.key, "")
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
for _, sub := range tt.want {
|
||||||
|
if !strings.Contains(w.Body.String(), sub) {
|
||||||
|
t.Errorf("应包含 %q: %s", sub, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, sub := range tt.notWant {
|
||||||
|
if strings.Contains(w.Body.String(), sub) {
|
||||||
|
t.Errorf("不应包含 %q: %s", sub, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAiKeyModelsUpdateRoundTrip(t *testing.T) {
|
||||||
|
r, auth, _, _ := newTestRouterDB(t)
|
||||||
|
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("login: %v", err)
|
||||||
|
}
|
||||||
|
w := doRequest(t, r, http.MethodPost, "/api/v1/ai-keys", token, `{"name":"k","value":"round-key-1234"}`)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("创建 status = %d", w.Code)
|
||||||
|
}
|
||||||
|
var created struct {
|
||||||
|
Item model.AiKey `json:"item"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &created)
|
||||||
|
id := created.Item.ID
|
||||||
|
|
||||||
|
listModels := func() []string {
|
||||||
|
w := doRequest(t, r, http.MethodGet, "/api/v1/ai-keys", token, "")
|
||||||
|
var resp struct {
|
||||||
|
Items []model.AiKey `json:"items"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
for _, k := range resp.Items {
|
||||||
|
if k.ID == id {
|
||||||
|
return k.Models
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("密钥 %d 不在列表中", id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"设置白名单", `{"models":[" a-model ","a-model","b-model"]}`, []string{"a-model", "b-model"}},
|
||||||
|
{"不传 models 保持不变", `{"name":"k2"}`, []string{"a-model", "b-model"}},
|
||||||
|
{"空数组清空恢复不限", `{"models":[]}`, nil},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
w := doRequest(t, r, http.MethodPut, "/api/v1/ai-keys/"+strconv.Itoa(int(id)), token, tt.body)
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("update status = %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
got := listModels()
|
||||||
|
if len(got) != len(tt.want) {
|
||||||
|
t.Fatalf("models = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
for i := range tt.want {
|
||||||
|
if got[i] != tt.want[i] {
|
||||||
|
t.Fatalf("models = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-11
@@ -15,7 +15,7 @@ import (
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Param body body object true "请求体(见接口说明)"
|
// @Param body body object true "请求体(见接口说明)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} publicIpResponse
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/change-public-ip [post]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/change-public-ip [post]
|
||||||
func (h *ociConfigHandler) changePublicIP(c *gin.Context) {
|
func (h *ociConfigHandler) changePublicIP(c *gin.Context) {
|
||||||
@@ -40,7 +40,7 @@ func (h *ociConfigHandler) changePublicIP(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Param body body object true "请求体(见接口说明)"
|
// @Param body body object true "请求体(见接口说明)"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} ipv6AddressResponse
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/ipv6-addresses [post]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/ipv6-addresses [post]
|
||||||
func (h *ociConfigHandler) addInstanceIpv6(c *gin.Context) {
|
func (h *ociConfigHandler) addInstanceIpv6(c *gin.Context) {
|
||||||
@@ -92,7 +92,7 @@ type attachVnicRequest struct {
|
|||||||
// @Tags 计算
|
// @Tags 计算
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.Vnic
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/vnics [get]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/vnics [get]
|
||||||
func (h *ociConfigHandler) listInstanceVnics(c *gin.Context) {
|
func (h *ociConfigHandler) listInstanceVnics(c *gin.Context) {
|
||||||
@@ -113,7 +113,7 @@ func (h *ociConfigHandler) listInstanceVnics(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Param body body attachVnicRequest true "请求体"
|
// @Param body body attachVnicRequest true "请求体"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} oci.Vnic
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/vnics [post]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/vnics [post]
|
||||||
func (h *ociConfigHandler) attachVnic(c *gin.Context) {
|
func (h *ociConfigHandler) attachVnic(c *gin.Context) {
|
||||||
@@ -160,7 +160,7 @@ func (h *ociConfigHandler) detachVnic(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param vnicId path string true "vnicId"
|
// @Param vnicId path string true "vnicId"
|
||||||
// @Param body body object true "请求体(见接口说明)"
|
// @Param body body object true "请求体(见接口说明)"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} addressResponse
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/vnics/{vnicId}/ipv6-addresses [post]
|
// @Router /api/v1/oci-configs/{id}/vnics/{vnicId}/ipv6-addresses [post]
|
||||||
func (h *ociConfigHandler) addVnicIpv6(c *gin.Context) {
|
func (h *ociConfigHandler) addVnicIpv6(c *gin.Context) {
|
||||||
@@ -195,7 +195,7 @@ type attachBootVolumeRequest struct {
|
|||||||
// @Tags 存储
|
// @Tags 存储
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.BootVolumeAttachment
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/boot-volume-attachments [get]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/boot-volume-attachments [get]
|
||||||
func (h *ociConfigHandler) listBootVolumeAttachments(c *gin.Context) {
|
func (h *ociConfigHandler) listBootVolumeAttachments(c *gin.Context) {
|
||||||
@@ -216,7 +216,7 @@ func (h *ociConfigHandler) listBootVolumeAttachments(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Param body body attachBootVolumeRequest true "请求体"
|
// @Param body body attachBootVolumeRequest true "请求体"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} oci.BootVolumeAttachment
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/boot-volume-attachments [post]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/boot-volume-attachments [post]
|
||||||
func (h *ociConfigHandler) attachBootVolume(c *gin.Context) {
|
func (h *ociConfigHandler) attachBootVolume(c *gin.Context) {
|
||||||
@@ -242,7 +242,7 @@ func (h *ociConfigHandler) attachBootVolume(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Param body body attachBootVolumeRequest true "请求体"
|
// @Param body body attachBootVolumeRequest true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.BootVolumeAttachment
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/replace-boot-volume [post]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/replace-boot-volume [post]
|
||||||
func (h *ociConfigHandler) replaceBootVolume(c *gin.Context) {
|
func (h *ociConfigHandler) replaceBootVolume(c *gin.Context) {
|
||||||
@@ -293,7 +293,7 @@ type attachVolumeRequest struct {
|
|||||||
// @Summary 块存储卷列表
|
// @Summary 块存储卷列表
|
||||||
// @Tags 存储
|
// @Tags 存储
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.BlockVolume
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/volumes [get]
|
// @Router /api/v1/oci-configs/{id}/volumes [get]
|
||||||
func (h *ociConfigHandler) listBlockVolumes(c *gin.Context) {
|
func (h *ociConfigHandler) listBlockVolumes(c *gin.Context) {
|
||||||
@@ -313,7 +313,7 @@ func (h *ociConfigHandler) listBlockVolumes(c *gin.Context) {
|
|||||||
// @Tags 存储
|
// @Tags 存储
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.VolumeAttachment
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/volume-attachments [get]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/volume-attachments [get]
|
||||||
func (h *ociConfigHandler) listVolumeAttachments(c *gin.Context) {
|
func (h *ociConfigHandler) listVolumeAttachments(c *gin.Context) {
|
||||||
@@ -334,7 +334,7 @@ func (h *ociConfigHandler) listVolumeAttachments(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Param body body attachVolumeRequest true "请求体"
|
// @Param body body attachVolumeRequest true "请求体"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} oci.VolumeAttachment
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/volume-attachments [post]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/volume-attachments [post]
|
||||||
func (h *ociConfigHandler) attachVolume(c *gin.Context) {
|
func (h *ociConfigHandler) attachVolume(c *gin.Context) {
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ type loginRequest struct {
|
|||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param body body loginRequest true "登录凭据"
|
// @Param body body loginRequest true "登录凭据"
|
||||||
// @Success 200 {object} map[string]any "token 与 expiresAt"
|
// @Success 200 {object} tokenResponse "token 与 expiresAt"
|
||||||
// @Failure 401 {object} map[string]string "凭据错误"
|
// @Failure 401 {object} errorResponse "凭据错误"
|
||||||
// @Failure 428 {object} map[string]any "需要两步验证码(totpRequired=true)"
|
// @Failure 428 {object} totpRequiredResponse "需要两步验证码(totpRequired=true)"
|
||||||
// @Router /api/v1/auth/login [post]
|
// @Router /api/v1/auth/login [post]
|
||||||
func (h *authHandler) login(c *gin.Context) {
|
func (h *authHandler) login(c *gin.Context) {
|
||||||
var req loginRequest
|
var req loginRequest
|
||||||
|
|||||||
+37
-12
@@ -5,9 +5,12 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
|
||||||
"oci-portal/internal/service"
|
"oci-portal/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,6 +18,7 @@ import (
|
|||||||
type authxHandler struct {
|
type authxHandler struct {
|
||||||
auth *service.AuthService
|
auth *service.AuthService
|
||||||
oauth *service.OAuthService
|
oauth *service.OAuthService
|
||||||
|
logs *service.SystemLogService
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- TOTP(JWT 组内) ----
|
// ---- TOTP(JWT 组内) ----
|
||||||
@@ -23,7 +27,7 @@ type authxHandler struct {
|
|||||||
//
|
//
|
||||||
// @Summary 两步验证状态
|
// @Summary 两步验证状态
|
||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Success 200 {object} map[string]bool "enabled"
|
// @Success 200 {object} enabledResponse "enabled"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/totp [get]
|
// @Router /api/v1/auth/totp [get]
|
||||||
func (h *authxHandler) totpStatus(c *gin.Context) {
|
func (h *authxHandler) totpStatus(c *gin.Context) {
|
||||||
@@ -39,8 +43,8 @@ func (h *authxHandler) totpStatus(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// @Summary 发起两步验证设置
|
// @Summary 发起两步验证设置
|
||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Success 200 {object} map[string]string "secret 与 otpauthUri"
|
// @Success 200 {object} totpSetupResponse "secret 与 otpauthUri"
|
||||||
// @Failure 409 {object} map[string]string "已启用"
|
// @Failure 409 {object} errorResponse "已启用"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/totp/setup [post]
|
// @Router /api/v1/auth/totp/setup [post]
|
||||||
func (h *authxHandler) totpSetup(c *gin.Context) {
|
func (h *authxHandler) totpSetup(c *gin.Context) {
|
||||||
@@ -128,7 +132,7 @@ func (h *authxHandler) respondFreshToken(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// @Summary 撤销全部会话
|
// @Summary 撤销全部会话
|
||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Success 200 {object} map[string]string "新 token 与 expiresAt"
|
// @Success 200 {object} tokenResponse "新 token 与 expiresAt"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/revoke-sessions [post]
|
// @Router /api/v1/auth/revoke-sessions [post]
|
||||||
func (h *authxHandler) revokeSessions(c *gin.Context) {
|
func (h *authxHandler) revokeSessions(c *gin.Context) {
|
||||||
@@ -146,7 +150,7 @@ func (h *authxHandler) revokeSessions(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// @Summary 登录凭据摘要
|
// @Summary 登录凭据摘要
|
||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Success 200 {object} map[string]any "username 与 passwordLoginDisabled"
|
// @Success 200 {object} credentialsResponse "username 与 passwordLoginDisabled"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/credentials [get]
|
// @Router /api/v1/auth/credentials [get]
|
||||||
func (h *authxHandler) getCredentials(c *gin.Context) {
|
func (h *authxHandler) getCredentials(c *gin.Context) {
|
||||||
@@ -167,7 +171,7 @@ func (h *authxHandler) getCredentials(c *gin.Context) {
|
|||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Param body body service.UpdateCredentialsInput true "新凭据(当前密码必验)"
|
// @Param body body service.UpdateCredentialsInput true "新凭据(当前密码必验)"
|
||||||
// @Success 204 "已更新,请重新登录"
|
// @Success 204 "已更新,请重新登录"
|
||||||
// @Failure 401 {object} map[string]string "当前密码错误"
|
// @Failure 401 {object} errorResponse "当前密码错误"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/credentials [put]
|
// @Router /api/v1/auth/credentials [put]
|
||||||
func (h *authxHandler) updateCredentials(c *gin.Context) {
|
func (h *authxHandler) updateCredentials(c *gin.Context) {
|
||||||
@@ -199,7 +203,7 @@ func (h *authxHandler) updateCredentials(c *gin.Context) {
|
|||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Param body body object true "{disabled: bool}"
|
// @Param body body object true "{disabled: bool}"
|
||||||
// @Success 204 "已保存"
|
// @Success 204 "已保存"
|
||||||
// @Failure 409 {object} map[string]string "未绑定外部身份"
|
// @Failure 409 {object} errorResponse "未绑定外部身份"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/password-login [put]
|
// @Router /api/v1/auth/password-login [put]
|
||||||
func (h *authxHandler) updatePasswordLogin(c *gin.Context) {
|
func (h *authxHandler) updatePasswordLogin(c *gin.Context) {
|
||||||
@@ -230,7 +234,7 @@ func (h *authxHandler) updatePasswordLogin(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// @Summary 外部登录 provider 列表
|
// @Summary 外部登录 provider 列表
|
||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Success 200 {object} map[string]any "providers 与 passwordLoginDisabled"
|
// @Success 200 {object} oauthProvidersResponse "providers 与 passwordLoginDisabled"
|
||||||
// @Router /api/v1/auth/oauth/providers [get]
|
// @Router /api/v1/auth/oauth/providers [get]
|
||||||
func (h *authxHandler) oauthProviders(c *gin.Context) {
|
func (h *authxHandler) oauthProviders(c *gin.Context) {
|
||||||
providers := h.oauth.Providers(c.Request.Context())
|
providers := h.oauth.Providers(c.Request.Context())
|
||||||
@@ -247,7 +251,7 @@ func (h *authxHandler) oauthProviders(c *gin.Context) {
|
|||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Param provider path string true "oidc / github"
|
// @Param provider path string true "oidc / github"
|
||||||
// @Param mode query string false "bind=绑定当前账号(需 Bearer),缺省登录"
|
// @Param mode query string false "bind=绑定当前账号(需 Bearer),缺省登录"
|
||||||
// @Success 200 {object} map[string]string "url"
|
// @Success 200 {object} urlResponse "url"
|
||||||
// @Router /api/v1/auth/oauth/{provider}/authorize [get]
|
// @Router /api/v1/auth/oauth/{provider}/authorize [get]
|
||||||
func (h *authxHandler) oauthAuthorize(c *gin.Context) {
|
func (h *authxHandler) oauthAuthorize(c *gin.Context) {
|
||||||
provider := c.Param("provider")
|
provider := c.Param("provider")
|
||||||
@@ -299,10 +303,12 @@ func (h *authxHandler) bearerUser(c *gin.Context) (string, bool) {
|
|||||||
// @Success 302 "登录 token 经 fragment 回前端,绑定回设置页"
|
// @Success 302 "登录 token 经 fragment 回前端,绑定回设置页"
|
||||||
// @Router /api/v1/auth/oauth/{provider}/callback [get]
|
// @Router /api/v1/auth/oauth/{provider}/callback [get]
|
||||||
func (h *authxHandler) oauthCallback(c *gin.Context) {
|
func (h *authxHandler) oauthCallback(c *gin.Context) {
|
||||||
|
start := time.Now()
|
||||||
provider := c.Param("provider")
|
provider := c.Param("provider")
|
||||||
token, _, mode, err := h.oauth.HandleCallback(
|
token, username, mode, err := h.oauth.HandleCallback(
|
||||||
c.Request.Context(), provider, c.Query("state"), c.Query("code"))
|
c.Request.Context(), provider, c.Query("state"), c.Query("code"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
h.recordOauth(c, username, http.StatusUnauthorized, oauthErrText(err), start)
|
||||||
target := "/login"
|
target := "/login"
|
||||||
if mode == "bind" {
|
if mode == "bind" {
|
||||||
target = "/settings"
|
target = "/settings"
|
||||||
@@ -310,6 +316,7 @@ func (h *authxHandler) oauthCallback(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusFound, target+"?oauthError="+url.QueryEscape(oauthErrText(err)))
|
c.Redirect(http.StatusFound, target+"?oauthError="+url.QueryEscape(oauthErrText(err)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.recordOauth(c, username, http.StatusOK, "", start)
|
||||||
if mode == "bind" {
|
if mode == "bind" {
|
||||||
// 绑定模式:版本已递增,新 token 经 fragment 带回设置页无感换发
|
// 绑定模式:版本已递增,新 token 经 fragment 带回设置页无感换发
|
||||||
c.Redirect(http.StatusFound, "/settings?oauth=bound#oauthToken="+url.QueryEscape(token))
|
c.Redirect(http.StatusFound, "/settings?oauth=bound#oauthToken="+url.QueryEscape(token))
|
||||||
@@ -319,6 +326,24 @@ func (h *authxHandler) oauthCallback(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusFound, "/login#oauthToken="+url.QueryEscape(token))
|
c.Redirect(http.StatusFound, "/login#oauthToken="+url.QueryEscape(token))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recordOauth 把外部登录 / 绑定结果记入系统日志——回调是 GET,
|
||||||
|
// 不经写方法中间件;实际响应恒为 302,日志按语义记 200 / 401。
|
||||||
|
func (h *authxHandler) recordOauth(c *gin.Context, username string, status int, errMsg string, start time.Time) {
|
||||||
|
if h.logs == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logs.Record(model.SystemLog{
|
||||||
|
Username: username,
|
||||||
|
Method: c.Request.Method,
|
||||||
|
Path: requestPath(c),
|
||||||
|
Status: status,
|
||||||
|
DurationMs: time.Since(start).Milliseconds(),
|
||||||
|
ClientIP: requestIP(c),
|
||||||
|
UserAgent: truncateLogField(c.Request.UserAgent(), 256),
|
||||||
|
ErrMsg: errMsg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// oauthErrText 把流程错误转为用户可读文案;内部错误不透出细节。
|
// oauthErrText 把流程错误转为用户可读文案;内部错误不透出细节。
|
||||||
func oauthErrText(err error) string {
|
func oauthErrText(err error) string {
|
||||||
for _, known := range []error{service.ErrOAuthNotConfigured, service.ErrOAuthNoAppURL, service.ErrOAuthDisabled, service.ErrOAuthState, service.ErrOAuthNotBound, service.ErrOAuthBound} {
|
for _, known := range []error{service.ErrOAuthNotConfigured, service.ErrOAuthNoAppURL, service.ErrOAuthDisabled, service.ErrOAuthState, service.ErrOAuthNotBound, service.ErrOAuthBound} {
|
||||||
@@ -333,7 +358,7 @@ func oauthErrText(err error) string {
|
|||||||
//
|
//
|
||||||
// @Summary 已绑定外部身份
|
// @Summary 已绑定外部身份
|
||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Success 200 {object} map[string]any "items"
|
// @Success 200 {object} itemsResponse[model.UserIdentity] "items"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/identities [get]
|
// @Router /api/v1/auth/identities [get]
|
||||||
func (h *authxHandler) identities(c *gin.Context) {
|
func (h *authxHandler) identities(c *gin.Context) {
|
||||||
@@ -351,7 +376,7 @@ func (h *authxHandler) identities(c *gin.Context) {
|
|||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Param id path int true "身份 ID"
|
// @Param id path int true "身份 ID"
|
||||||
// @Success 204 "已解绑"
|
// @Success 204 "已解绑"
|
||||||
// @Failure 409 {object} map[string]string "最后一个身份不可解绑"
|
// @Failure 409 {object} errorResponse "最后一个身份不可解绑"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/identities/{id} [delete]
|
// @Router /api/v1/auth/identities/{id} [delete]
|
||||||
func (h *authxHandler) unbindIdentity(c *gin.Context) {
|
func (h *authxHandler) unbindIdentity(c *gin.Context) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
_ "oci-portal/internal/oci" // swagger 注解引用
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---- 控制台连接(VNC / 串口) ----
|
// ---- 控制台连接(VNC / 串口) ----
|
||||||
@@ -18,7 +20,7 @@ type createConsoleConnectionRequest struct {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Param body body createConsoleConnectionRequest true "请求体"
|
// @Param body body createConsoleConnectionRequest true "请求体"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} oci.ConsoleConnection
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/console-connections [post]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/console-connections [post]
|
||||||
func (h *ociConfigHandler) createConsoleConnection(c *gin.Context) {
|
func (h *ociConfigHandler) createConsoleConnection(c *gin.Context) {
|
||||||
@@ -43,7 +45,7 @@ func (h *ociConfigHandler) createConsoleConnection(c *gin.Context) {
|
|||||||
// @Tags 计算
|
// @Tags 计算
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.ConsoleConnection
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/console-connections [get]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/console-connections [get]
|
||||||
func (h *ociConfigHandler) listConsoleConnections(c *gin.Context) {
|
func (h *ociConfigHandler) listConsoleConnections(c *gin.Context) {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func boolOr(v *bool, def bool) bool {
|
|||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.IdentityProviderInfo
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/identity-providers [get]
|
// @Router /api/v1/oci-configs/{id}/identity-providers [get]
|
||||||
func (h *ociConfigHandler) listIdentityProviders(c *gin.Context) {
|
func (h *ociConfigHandler) listIdentityProviders(c *gin.Context) {
|
||||||
@@ -62,7 +62,7 @@ func (h *ociConfigHandler) listIdentityProviders(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param body body createIdpRequest true "请求体"
|
// @Param body body createIdpRequest true "请求体"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} oci.IdentityProviderInfo
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/identity-providers [post]
|
// @Router /api/v1/oci-configs/{id}/identity-providers [post]
|
||||||
func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
|
func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
|
||||||
@@ -102,7 +102,7 @@ func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
|
|||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param idpId path string true "idpId"
|
// @Param idpId path string true "idpId"
|
||||||
// @Param body body object true "请求体(见接口说明)"
|
// @Param body body object true "请求体(见接口说明)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.IdentityProviderInfo
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/identity-providers/{idpId}/activate [post]
|
// @Router /api/v1/oci-configs/{id}/identity-providers/{idpId}/activate [post]
|
||||||
func (h *ociConfigHandler) activateIdentityProvider(c *gin.Context) {
|
func (h *ociConfigHandler) activateIdentityProvider(c *gin.Context) {
|
||||||
@@ -149,7 +149,7 @@ func (h *ociConfigHandler) deleteIdentityProvider(c *gin.Context) {
|
|||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {file} file "SAML 元数据 XML(附件下载)"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/saml-metadata [get]
|
// @Router /api/v1/oci-configs/{id}/saml-metadata [get]
|
||||||
func (h *ociConfigHandler) downloadSamlMetadata(c *gin.Context) {
|
func (h *ociConfigHandler) downloadSamlMetadata(c *gin.Context) {
|
||||||
@@ -170,7 +170,7 @@ func (h *ociConfigHandler) downloadSamlMetadata(c *gin.Context) {
|
|||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.SignOnRuleInfo
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/sign-on-rules [get]
|
// @Router /api/v1/oci-configs/{id}/sign-on-rules [get]
|
||||||
func (h *ociConfigHandler) listSignOnRules(c *gin.Context) {
|
func (h *ociConfigHandler) listSignOnRules(c *gin.Context) {
|
||||||
@@ -191,7 +191,7 @@ func (h *ociConfigHandler) listSignOnRules(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param body body object true "请求体(见接口说明)"
|
// @Param body body object true "请求体(见接口说明)"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} oci.SignOnRuleInfo
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/sign-on-exemptions [post]
|
// @Router /api/v1/oci-configs/{id}/sign-on-exemptions [post]
|
||||||
func (h *ociConfigHandler) createMfaExemption(c *gin.Context) {
|
func (h *ociConfigHandler) createMfaExemption(c *gin.Context) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
// @Summary 可用域列表
|
// @Summary 可用域列表
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} string
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/availability-domains [get]
|
// @Router /api/v1/oci-configs/{id}/availability-domains [get]
|
||||||
func (h *ociConfigHandler) availabilityDomains(c *gin.Context) {
|
func (h *ociConfigHandler) availabilityDomains(c *gin.Context) {
|
||||||
@@ -67,7 +67,7 @@ type instanceActionRequest struct {
|
|||||||
// @Summary 实例列表
|
// @Summary 实例列表
|
||||||
// @Tags 计算
|
// @Tags 计算
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.Instance
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances [get]
|
// @Router /api/v1/oci-configs/{id}/instances [get]
|
||||||
func (h *ociConfigHandler) listInstances(c *gin.Context) {
|
func (h *ociConfigHandler) listInstances(c *gin.Context) {
|
||||||
@@ -87,7 +87,7 @@ func (h *ociConfigHandler) listInstances(c *gin.Context) {
|
|||||||
// @Tags 计算
|
// @Tags 计算
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param body body createInstanceRequest true "请求体"
|
// @Param body body createInstanceRequest true "请求体"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} createInstancesResponse "部分成功仍 201,errors 为逐台失败信息"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances [post]
|
// @Router /api/v1/oci-configs/{id}/instances [post]
|
||||||
func (h *ociConfigHandler) createInstance(c *gin.Context) {
|
func (h *ociConfigHandler) createInstance(c *gin.Context) {
|
||||||
@@ -147,7 +147,7 @@ func (h *ociConfigHandler) createInstance(c *gin.Context) {
|
|||||||
// @Tags 计算
|
// @Tags 计算
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.Instance
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId} [get]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId} [get]
|
||||||
func (h *ociConfigHandler) getInstance(c *gin.Context) {
|
func (h *ociConfigHandler) getInstance(c *gin.Context) {
|
||||||
@@ -168,7 +168,7 @@ func (h *ociConfigHandler) getInstance(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Param body body updateInstanceRequest true "请求体"
|
// @Param body body updateInstanceRequest true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.Instance
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId} [put]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId} [put]
|
||||||
func (h *ociConfigHandler) updateInstance(c *gin.Context) {
|
func (h *ociConfigHandler) updateInstance(c *gin.Context) {
|
||||||
@@ -220,7 +220,7 @@ func (h *ociConfigHandler) terminateInstance(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Param body body instanceActionRequest true "请求体"
|
// @Param body body instanceActionRequest true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.Instance
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/action [post]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/action [post]
|
||||||
func (h *ociConfigHandler) instanceAction(c *gin.Context) {
|
func (h *ociConfigHandler) instanceAction(c *gin.Context) {
|
||||||
@@ -253,7 +253,7 @@ type updateBootVolumeRequest struct {
|
|||||||
// @Summary 引导卷列表
|
// @Summary 引导卷列表
|
||||||
// @Tags 存储
|
// @Tags 存储
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.BootVolume
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/boot-volumes [get]
|
// @Router /api/v1/oci-configs/{id}/boot-volumes [get]
|
||||||
func (h *ociConfigHandler) listBootVolumes(c *gin.Context) {
|
func (h *ociConfigHandler) listBootVolumes(c *gin.Context) {
|
||||||
@@ -273,7 +273,7 @@ func (h *ociConfigHandler) listBootVolumes(c *gin.Context) {
|
|||||||
// @Tags 存储
|
// @Tags 存储
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param bootVolumeId path string true "bootVolumeId"
|
// @Param bootVolumeId path string true "bootVolumeId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.BootVolume
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/boot-volumes/{bootVolumeId} [get]
|
// @Router /api/v1/oci-configs/{id}/boot-volumes/{bootVolumeId} [get]
|
||||||
func (h *ociConfigHandler) getBootVolume(c *gin.Context) {
|
func (h *ociConfigHandler) getBootVolume(c *gin.Context) {
|
||||||
@@ -294,7 +294,7 @@ func (h *ociConfigHandler) getBootVolume(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param bootVolumeId path string true "bootVolumeId"
|
// @Param bootVolumeId path string true "bootVolumeId"
|
||||||
// @Param body body updateBootVolumeRequest true "请求体"
|
// @Param body body updateBootVolumeRequest true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.BootVolume
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/boot-volumes/{bootVolumeId} [put]
|
// @Router /api/v1/oci-configs/{id}/boot-volumes/{bootVolumeId} [put]
|
||||||
func (h *ociConfigHandler) updateBootVolume(c *gin.Context) {
|
func (h *ociConfigHandler) updateBootVolume(c *gin.Context) {
|
||||||
|
|||||||
+7
-127
@@ -7,7 +7,8 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"oci-portal/internal/model"
|
_ "oci-portal/internal/model" // swagger 注解引用
|
||||||
|
|
||||||
"oci-portal/internal/service"
|
"oci-portal/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ type logEventHandler struct {
|
|||||||
// @Summary 查询配置的回调地址
|
// @Summary 查询配置的回调地址
|
||||||
// @Tags 任务与日志回传
|
// @Tags 任务与日志回传
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} logWebhookStatusResponse
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/log-webhook [get]
|
// @Router /api/v1/oci-configs/{id}/log-webhook [get]
|
||||||
func (h *logEventHandler) getWebhook(c *gin.Context) {
|
func (h *logEventHandler) getWebhook(c *gin.Context) {
|
||||||
@@ -46,7 +47,7 @@ func (h *logEventHandler) getWebhook(c *gin.Context) {
|
|||||||
// @Summary 生成
|
// @Summary 生成
|
||||||
// @Tags 任务与日志回传
|
// @Tags 任务与日志回传
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.LogWebhookInfo
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/log-webhook [post]
|
// @Router /api/v1/oci-configs/{id}/log-webhook [post]
|
||||||
func (h *logEventHandler) ensureWebhook(c *gin.Context) {
|
func (h *logEventHandler) ensureWebhook(c *gin.Context) {
|
||||||
@@ -88,7 +89,7 @@ func (h *logEventHandler) revokeWebhook(c *gin.Context) {
|
|||||||
// @Tags 任务与日志回传
|
// @Tags 任务与日志回传
|
||||||
// @Param configId query int false "按配置过滤"
|
// @Param configId query int false "按配置过滤"
|
||||||
// @Param q query string false "关键字"
|
// @Param q query string false "关键字"
|
||||||
// @Success 200 {object} map[string]any "items 与 total"
|
// @Success 200 {object} pagedResponse[model.LogEvent] "items 与 total"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/log-events [get]
|
// @Router /api/v1/log-events [get]
|
||||||
func (h *logEventHandler) list(c *gin.Context) {
|
func (h *logEventHandler) list(c *gin.Context) {
|
||||||
@@ -107,133 +108,12 @@ func (h *logEventHandler) list(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
|
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
|
||||||
}
|
}
|
||||||
|
|
||||||
// alertRuleRequest 是创建/更新告警规则的请求体。
|
|
||||||
type alertRuleRequest struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
OciConfigID uint `json:"ociConfigId"`
|
|
||||||
EventTypes string `json:"eventTypes"`
|
|
||||||
SourceIPs string `json:"sourceIps"`
|
|
||||||
SourceIPMode string `json:"sourceIpMode"`
|
|
||||||
ResourceMatch string `json:"resourceMatch"`
|
|
||||||
Threshold int `json:"threshold"`
|
|
||||||
WindowMinutes int `json:"windowMinutes"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// toModel 转为规则模型(默认阈值 1)。
|
|
||||||
func (r alertRuleRequest) toModel() model.AlertRule {
|
|
||||||
if r.Threshold == 0 {
|
|
||||||
r.Threshold = 1
|
|
||||||
}
|
|
||||||
return model.AlertRule{
|
|
||||||
Name: r.Name, Enabled: r.Enabled, OciConfigID: r.OciConfigID,
|
|
||||||
EventTypes: r.EventTypes, SourceIPs: r.SourceIPs, SourceIPMode: r.SourceIPMode,
|
|
||||||
ResourceMatch: r.ResourceMatch, Threshold: r.Threshold, WindowMinutes: r.WindowMinutes,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// listAlertRules 返回全部告警规则。
|
|
||||||
//
|
|
||||||
// @Summary 告警规则列表
|
|
||||||
// @Tags 任务与日志回传
|
|
||||||
// @Success 200 {object} map[string]any
|
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/v1/log-events/alert-rules [get]
|
|
||||||
func (h *logEventHandler) listAlertRules(c *gin.Context) {
|
|
||||||
rules, err := h.svc.ListAlertRules(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"items": rules})
|
|
||||||
}
|
|
||||||
|
|
||||||
// createAlertRule 创建告警规则;字段非法返回 400。
|
|
||||||
//
|
|
||||||
// @Summary 创建告警规则
|
|
||||||
// @Tags 任务与日志回传
|
|
||||||
// @Param body body alertRuleRequest true "请求体"
|
|
||||||
// @Success 201 {object} map[string]any
|
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/v1/log-events/alert-rules [post]
|
|
||||||
func (h *logEventHandler) createAlertRule(c *gin.Context) {
|
|
||||||
var req alertRuleRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rule, err := h.svc.CreateAlertRule(c.Request.Context(), req.toModel())
|
|
||||||
if err != nil {
|
|
||||||
respondAlertRuleError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusCreated, rule)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateAlertRule 整体覆盖告警规则(含启停)。
|
|
||||||
//
|
|
||||||
// @Summary 更新告警规则
|
|
||||||
// @Tags 任务与日志回传
|
|
||||||
// @Param ruleId path int true "规则 ID"
|
|
||||||
// @Param body body alertRuleRequest true "请求体"
|
|
||||||
// @Success 200 {object} map[string]any
|
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/v1/log-events/alert-rules/{ruleId} [put]
|
|
||||||
func (h *logEventHandler) updateAlertRule(c *gin.Context) {
|
|
||||||
id, err := strconv.ParseUint(c.Param("ruleId"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "规则 ID 非法"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req alertRuleRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rule, err := h.svc.UpdateAlertRule(c.Request.Context(), uint(id), req.toModel())
|
|
||||||
if err != nil {
|
|
||||||
respondAlertRuleError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, rule)
|
|
||||||
}
|
|
||||||
|
|
||||||
// deleteAlertRule 删除告警规则及其命中记录。
|
|
||||||
//
|
|
||||||
// @Summary 删除告警规则
|
|
||||||
// @Tags 任务与日志回传
|
|
||||||
// @Param ruleId path int true "规则 ID"
|
|
||||||
// @Success 204 "无内容"
|
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/v1/log-events/alert-rules/{ruleId} [delete]
|
|
||||||
func (h *logEventHandler) deleteAlertRule(c *gin.Context) {
|
|
||||||
id, err := strconv.ParseUint(c.Param("ruleId"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "规则 ID 非法"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := h.svc.DeleteAlertRule(c.Request.Context(), uint(id)); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// respondAlertRuleError 把字段校验错误映射为 400。
|
|
||||||
func respondAlertRuleError(c *gin.Context, err error) {
|
|
||||||
if errors.Is(err, service.ErrInvalidAlertRule) {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(c, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getRelay 查询 OCI 侧链路状态与关键事件清单。
|
// getRelay 查询 OCI 侧链路状态与关键事件清单。
|
||||||
//
|
//
|
||||||
// @Summary 查询 OCI 侧链路状态与关键事件清单
|
// @Summary 查询 OCI 侧链路状态与关键事件清单
|
||||||
// @Tags 任务与日志回传
|
// @Tags 任务与日志回传
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.RelayView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/log-relay [get]
|
// @Router /api/v1/oci-configs/{id}/log-relay [get]
|
||||||
func (h *logEventHandler) getRelay(c *gin.Context) {
|
func (h *logEventHandler) getRelay(c *gin.Context) {
|
||||||
@@ -254,7 +134,7 @@ func (h *logEventHandler) getRelay(c *gin.Context) {
|
|||||||
// @Summary 一键建立回传链路
|
// @Summary 一键建立回传链路
|
||||||
// @Tags 任务与日志回传
|
// @Tags 任务与日志回传
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.RelayView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/log-relay [post]
|
// @Router /api/v1/oci-configs/{id}/log-relay [post]
|
||||||
func (h *logEventHandler) setupRelay(c *gin.Context) {
|
func (h *logEventHandler) setupRelay(c *gin.Context) {
|
||||||
|
|||||||
+16
-16
@@ -11,7 +11,7 @@ import (
|
|||||||
// @Summary 实例形状列表
|
// @Summary 实例形状列表
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.ComputeShape
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/shapes [get]
|
// @Router /api/v1/oci-configs/{id}/shapes [get]
|
||||||
func (h *ociConfigHandler) shapes(c *gin.Context) {
|
func (h *ociConfigHandler) shapes(c *gin.Context) {
|
||||||
@@ -30,7 +30,7 @@ func (h *ociConfigHandler) shapes(c *gin.Context) {
|
|||||||
// @Summary 镜像列表
|
// @Summary 镜像列表
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.Image
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/images [get]
|
// @Router /api/v1/oci-configs/{id}/images [get]
|
||||||
func (h *ociConfigHandler) images(c *gin.Context) {
|
func (h *ociConfigHandler) images(c *gin.Context) {
|
||||||
@@ -54,7 +54,7 @@ func (h *ociConfigHandler) images(c *gin.Context) {
|
|||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param imageId path string true "imageId"
|
// @Param imageId path string true "imageId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.Image
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/images/{imageId} [get]
|
// @Router /api/v1/oci-configs/{id}/images/{imageId} [get]
|
||||||
func (h *ociConfigHandler) getImage(c *gin.Context) {
|
func (h *ociConfigHandler) getImage(c *gin.Context) {
|
||||||
@@ -89,7 +89,7 @@ type renameRequest struct {
|
|||||||
// @Summary VCN 列表
|
// @Summary VCN 列表
|
||||||
// @Tags 网络
|
// @Tags 网络
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.VCN
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/vcns [get]
|
// @Router /api/v1/oci-configs/{id}/vcns [get]
|
||||||
func (h *ociConfigHandler) listVCNs(c *gin.Context) {
|
func (h *ociConfigHandler) listVCNs(c *gin.Context) {
|
||||||
@@ -109,7 +109,7 @@ func (h *ociConfigHandler) listVCNs(c *gin.Context) {
|
|||||||
// @Tags 网络
|
// @Tags 网络
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param body body createVCNRequest true "请求体"
|
// @Param body body createVCNRequest true "请求体"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} oci.VCN
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/vcns [post]
|
// @Router /api/v1/oci-configs/{id}/vcns [post]
|
||||||
func (h *ociConfigHandler) createVCN(c *gin.Context) {
|
func (h *ociConfigHandler) createVCN(c *gin.Context) {
|
||||||
@@ -141,7 +141,7 @@ func (h *ociConfigHandler) createVCN(c *gin.Context) {
|
|||||||
// @Tags 网络
|
// @Tags 网络
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param vcnId path string true "vcnId"
|
// @Param vcnId path string true "vcnId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.VCN
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/vcns/{vcnId} [get]
|
// @Router /api/v1/oci-configs/{id}/vcns/{vcnId} [get]
|
||||||
func (h *ociConfigHandler) getVCN(c *gin.Context) {
|
func (h *ociConfigHandler) getVCN(c *gin.Context) {
|
||||||
@@ -162,7 +162,7 @@ func (h *ociConfigHandler) getVCN(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param vcnId path string true "vcnId"
|
// @Param vcnId path string true "vcnId"
|
||||||
// @Param body body renameRequest true "请求体"
|
// @Param body body renameRequest true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.VCN
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/vcns/{vcnId} [put]
|
// @Router /api/v1/oci-configs/{id}/vcns/{vcnId} [put]
|
||||||
func (h *ociConfigHandler) updateVCN(c *gin.Context) {
|
func (h *ociConfigHandler) updateVCN(c *gin.Context) {
|
||||||
@@ -211,7 +211,7 @@ type enableIPv6Request struct {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param vcnId path string true "vcnId"
|
// @Param vcnId path string true "vcnId"
|
||||||
// @Param body body enableIPv6Request true "请求体"
|
// @Param body body enableIPv6Request true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} ipv6StepsResponse
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/vcns/{vcnId}/enable-ipv6 [post]
|
// @Router /api/v1/oci-configs/{id}/vcns/{vcnId}/enable-ipv6 [post]
|
||||||
func (h *ociConfigHandler) enableVCNIPv6(c *gin.Context) {
|
func (h *ociConfigHandler) enableVCNIPv6(c *gin.Context) {
|
||||||
@@ -247,7 +247,7 @@ type createSubnetRequest struct {
|
|||||||
// @Summary 子网列表
|
// @Summary 子网列表
|
||||||
// @Tags 网络
|
// @Tags 网络
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.Subnet
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/subnets [get]
|
// @Router /api/v1/oci-configs/{id}/subnets [get]
|
||||||
func (h *ociConfigHandler) listSubnets(c *gin.Context) {
|
func (h *ociConfigHandler) listSubnets(c *gin.Context) {
|
||||||
@@ -267,7 +267,7 @@ func (h *ociConfigHandler) listSubnets(c *gin.Context) {
|
|||||||
// @Tags 网络
|
// @Tags 网络
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param body body createSubnetRequest true "请求体"
|
// @Param body body createSubnetRequest true "请求体"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} oci.Subnet
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/subnets [post]
|
// @Router /api/v1/oci-configs/{id}/subnets [post]
|
||||||
func (h *ociConfigHandler) createSubnet(c *gin.Context) {
|
func (h *ociConfigHandler) createSubnet(c *gin.Context) {
|
||||||
@@ -300,7 +300,7 @@ func (h *ociConfigHandler) createSubnet(c *gin.Context) {
|
|||||||
// @Tags 网络
|
// @Tags 网络
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param subnetId path string true "subnetId"
|
// @Param subnetId path string true "subnetId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.Subnet
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/subnets/{subnetId} [get]
|
// @Router /api/v1/oci-configs/{id}/subnets/{subnetId} [get]
|
||||||
func (h *ociConfigHandler) getSubnet(c *gin.Context) {
|
func (h *ociConfigHandler) getSubnet(c *gin.Context) {
|
||||||
@@ -321,7 +321,7 @@ func (h *ociConfigHandler) getSubnet(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param subnetId path string true "subnetId"
|
// @Param subnetId path string true "subnetId"
|
||||||
// @Param body body renameRequest true "请求体"
|
// @Param body body renameRequest true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.Subnet
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/subnets/{subnetId} [put]
|
// @Router /api/v1/oci-configs/{id}/subnets/{subnetId} [put]
|
||||||
func (h *ociConfigHandler) updateSubnet(c *gin.Context) {
|
func (h *ociConfigHandler) updateSubnet(c *gin.Context) {
|
||||||
@@ -381,7 +381,7 @@ type updateSecurityListRequest struct {
|
|||||||
// @Summary 安全列表清单
|
// @Summary 安全列表清单
|
||||||
// @Tags 网络
|
// @Tags 网络
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.SecurityList
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/security-lists [get]
|
// @Router /api/v1/oci-configs/{id}/security-lists [get]
|
||||||
func (h *ociConfigHandler) listSecurityLists(c *gin.Context) {
|
func (h *ociConfigHandler) listSecurityLists(c *gin.Context) {
|
||||||
@@ -401,7 +401,7 @@ func (h *ociConfigHandler) listSecurityLists(c *gin.Context) {
|
|||||||
// @Tags 网络
|
// @Tags 网络
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param body body createSecurityListRequest true "请求体"
|
// @Param body body createSecurityListRequest true "请求体"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} oci.SecurityList
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/security-lists [post]
|
// @Router /api/v1/oci-configs/{id}/security-lists [post]
|
||||||
func (h *ociConfigHandler) createSecurityList(c *gin.Context) {
|
func (h *ociConfigHandler) createSecurityList(c *gin.Context) {
|
||||||
@@ -432,7 +432,7 @@ func (h *ociConfigHandler) createSecurityList(c *gin.Context) {
|
|||||||
// @Tags 网络
|
// @Tags 网络
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param securityListId path string true "securityListId"
|
// @Param securityListId path string true "securityListId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.SecurityList
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/security-lists/{securityListId} [get]
|
// @Router /api/v1/oci-configs/{id}/security-lists/{securityListId} [get]
|
||||||
func (h *ociConfigHandler) getSecurityList(c *gin.Context) {
|
func (h *ociConfigHandler) getSecurityList(c *gin.Context) {
|
||||||
@@ -453,7 +453,7 @@ func (h *ociConfigHandler) getSecurityList(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param securityListId path string true "securityListId"
|
// @Param securityListId path string true "securityListId"
|
||||||
// @Param body body updateSecurityListRequest true "请求体"
|
// @Param body body updateSecurityListRequest true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.SecurityList
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/security-lists/{securityListId} [put]
|
// @Router /api/v1/oci-configs/{id}/security-lists/{securityListId} [put]
|
||||||
func (h *ociConfigHandler) updateSecurityList(c *gin.Context) {
|
func (h *ociConfigHandler) updateSecurityList(c *gin.Context) {
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/oracle/oci-go-sdk/v65/common"
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
_ "oci-portal/internal/model" // swagger 注解引用
|
||||||
|
|
||||||
"oci-portal/internal/oci"
|
"oci-portal/internal/oci"
|
||||||
"oci-portal/internal/service"
|
"oci-portal/internal/service"
|
||||||
@@ -39,7 +41,7 @@ type importRequest struct {
|
|||||||
// @Summary 导入租户配置
|
// @Summary 导入租户配置
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param body body importRequest true "API Key 配置(私钥密文落库)"
|
// @Param body body importRequest true "API Key 配置(私钥密文落库)"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} model.OciConfig
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs [post]
|
// @Router /api/v1/oci-configs [post]
|
||||||
func (h *ociConfigHandler) create(c *gin.Context) {
|
func (h *ociConfigHandler) create(c *gin.Context) {
|
||||||
@@ -71,7 +73,7 @@ func (h *ociConfigHandler) create(c *gin.Context) {
|
|||||||
|
|
||||||
// @Summary 租户配置列表
|
// @Summary 租户配置列表
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Success 200 {object} map[string]any "items"
|
// @Success 200 {array} service.ConfigSummary
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs [get]
|
// @Router /api/v1/oci-configs [get]
|
||||||
func (h *ociConfigHandler) list(c *gin.Context) {
|
func (h *ociConfigHandler) list(c *gin.Context) {
|
||||||
@@ -86,7 +88,7 @@ func (h *ociConfigHandler) list(c *gin.Context) {
|
|||||||
// @Summary 租户配置详情
|
// @Summary 租户配置详情
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} model.OciConfig
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id} [get]
|
// @Router /api/v1/oci-configs/{id} [get]
|
||||||
func (h *ociConfigHandler) get(c *gin.Context) {
|
func (h *ociConfigHandler) get(c *gin.Context) {
|
||||||
@@ -105,7 +107,7 @@ func (h *ociConfigHandler) get(c *gin.Context) {
|
|||||||
// @Summary 验证配置连通性并刷新画像
|
// @Summary 验证配置连通性并刷新画像
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} configChangesResponse "config 与 changes 字段变更对照"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/verify [post]
|
// @Router /api/v1/oci-configs/{id}/verify [post]
|
||||||
func (h *ociConfigHandler) verify(c *gin.Context) {
|
func (h *ociConfigHandler) verify(c *gin.Context) {
|
||||||
@@ -139,7 +141,7 @@ type updateConfigRequest struct {
|
|||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param body body object true "可更新字段(别名/分组/代理/多区域开关等)"
|
// @Param body body object true "可更新字段(别名/分组/代理/多区域开关等)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} configChangesResponse "config 与 changes 字段变更对照"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id} [put]
|
// @Router /api/v1/oci-configs/{id} [put]
|
||||||
func (h *ociConfigHandler) update(c *gin.Context) {
|
func (h *ociConfigHandler) update(c *gin.Context) {
|
||||||
@@ -194,7 +196,7 @@ func (h *ociConfigHandler) remove(c *gin.Context) {
|
|||||||
// @Summary 列出租户下全部 ACTIVE compartment
|
// @Summary 列出租户下全部 ACTIVE compartment
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.Compartment
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/compartments [get]
|
// @Router /api/v1/oci-configs/{id}/compartments [get]
|
||||||
func (h *ociConfigHandler) compartments(c *gin.Context) {
|
func (h *ociConfigHandler) compartments(c *gin.Context) {
|
||||||
@@ -216,7 +218,7 @@ func (h *ociConfigHandler) compartments(c *gin.Context) {
|
|||||||
// @Summary 返回筛选器用区域列表:未开多区域支持只含默认区域
|
// @Summary 返回筛选器用区域列表:未开多区域支持只含默认区域
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} service.RegionSubscriptionView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/cached-regions [get]
|
// @Router /api/v1/oci-configs/{id}/cached-regions [get]
|
||||||
func (h *ociConfigHandler) cachedRegions(c *gin.Context) {
|
func (h *ociConfigHandler) cachedRegions(c *gin.Context) {
|
||||||
@@ -237,7 +239,7 @@ func (h *ociConfigHandler) cachedRegions(c *gin.Context) {
|
|||||||
// @Summary 返回筛选器用区间列表:未开多区间支持返回空数组
|
// @Summary 返回筛选器用区间列表:未开多区间支持返回空数组
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.Compartment
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/cached-compartments [get]
|
// @Router /api/v1/oci-configs/{id}/cached-compartments [get]
|
||||||
func (h *ociConfigHandler) cachedCompartments(c *gin.Context) {
|
func (h *ociConfigHandler) cachedCompartments(c *gin.Context) {
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
_ "oci-portal/internal/service" // swagger 注解引用
|
||||||
)
|
)
|
||||||
|
|
||||||
// overview 返回总览页聚合数据(本地快照,不发云端请求)。
|
// overview 返回总览页聚合数据(本地快照,不发云端请求)。
|
||||||
//
|
//
|
||||||
// @Summary 返回总览页聚合数据
|
// @Summary 返回总览页聚合数据
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.Overview
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/overview [get]
|
// @Router /api/v1/overview [get]
|
||||||
func (h *ociConfigHandler) overview(c *gin.Context) {
|
func (h *ociConfigHandler) overview(c *gin.Context) {
|
||||||
|
|||||||
+34
-5
@@ -18,7 +18,7 @@ type proxyHandler struct {
|
|||||||
//
|
//
|
||||||
// @Summary 代理列表
|
// @Summary 代理列表
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Success 200 {object} map[string]any "items"
|
// @Success 200 {object} itemsResponse[service.ProxyView] "items"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/proxies [get]
|
// @Router /api/v1/proxies [get]
|
||||||
func (h *proxyHandler) list(c *gin.Context) {
|
func (h *proxyHandler) list(c *gin.Context) {
|
||||||
@@ -35,7 +35,7 @@ func (h *proxyHandler) list(c *gin.Context) {
|
|||||||
// @Summary 创建代理
|
// @Summary 创建代理
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Param body body service.ProxyInput true "代理配置(密码只写不回)"
|
// @Param body body service.ProxyInput true "代理配置(密码只写不回)"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} service.ProxyView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/proxies [post]
|
// @Router /api/v1/proxies [post]
|
||||||
func (h *proxyHandler) create(c *gin.Context) {
|
func (h *proxyHandler) create(c *gin.Context) {
|
||||||
@@ -58,7 +58,7 @@ func (h *proxyHandler) create(c *gin.Context) {
|
|||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Param id path int true "代理 ID"
|
// @Param id path int true "代理 ID"
|
||||||
// @Param body body service.ProxyInput true "代理配置(密码缺省沿用)"
|
// @Param body body service.ProxyInput true "代理配置(密码缺省沿用)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.ProxyView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/proxies/{id} [put]
|
// @Router /api/v1/proxies/{id} [put]
|
||||||
func (h *proxyHandler) update(c *gin.Context) {
|
func (h *proxyHandler) update(c *gin.Context) {
|
||||||
@@ -104,7 +104,7 @@ func (h *proxyHandler) remove(c *gin.Context) {
|
|||||||
// @Summary 批量导入代理
|
// @Summary 批量导入代理
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Param body body object true "请求体(见接口说明)"
|
// @Param body body object true "请求体(见接口说明)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.ImportResult
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/proxies/import [post]
|
// @Router /api/v1/proxies/import [post]
|
||||||
func (h *proxyHandler) importBatch(c *gin.Context) {
|
func (h *proxyHandler) importBatch(c *gin.Context) {
|
||||||
@@ -128,7 +128,7 @@ func (h *proxyHandler) importBatch(c *gin.Context) {
|
|||||||
// @Summary 手动重测代理出口地区,同步返回最新视图
|
// @Summary 手动重测代理出口地区,同步返回最新视图
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.ProxyView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/proxies/{id}/probe [post]
|
// @Router /api/v1/proxies/{id}/probe [post]
|
||||||
func (h *proxyHandler) probe(c *gin.Context) {
|
func (h *proxyHandler) probe(c *gin.Context) {
|
||||||
@@ -144,6 +144,35 @@ func (h *proxyHandler) probe(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, view)
|
c.JSON(http.StatusOK, view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setTenants 设置代理关联的租户全集(代理侧批量关联 / 解除)。
|
||||||
|
//
|
||||||
|
// @Summary 设置代理关联的租户全集
|
||||||
|
// @Tags 设置
|
||||||
|
// @Param id path int true "代理 ID"
|
||||||
|
// @Param body body object true "请求体 {\"ociConfigIds\": [1,2]}"
|
||||||
|
// @Success 200 {object} usedByResponse "usedBy"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/proxies/{id}/tenants [put]
|
||||||
|
func (h *proxyHandler) setTenants(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
OciConfigIDs []uint `json:"ociConfigIds"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
used, err := h.svc.SetTenants(c.Request.Context(), id, req.OciConfigIDs)
|
||||||
|
if err != nil {
|
||||||
|
h.respond(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"usedBy": used})
|
||||||
|
}
|
||||||
|
|
||||||
// respond 把代理业务错误映射到语义状态码。
|
// respond 把代理业务错误映射到语义状态码。
|
||||||
func (h *proxyHandler) respond(c *gin.Context, err error) {
|
func (h *proxyHandler) respond(c *gin.Context, err error) {
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
_ "oci-portal/internal/service" // swagger 注解引用
|
||||||
|
|
||||||
"oci-portal/internal/oci"
|
"oci-portal/internal/oci"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,7 +15,7 @@ import (
|
|||||||
//
|
//
|
||||||
// @Summary 返回本地维护的完整区域表
|
// @Summary 返回本地维护的完整区域表
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.RegionInfo
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/regions [get]
|
// @Router /api/v1/regions [get]
|
||||||
func listRegions(c *gin.Context) {
|
func listRegions(c *gin.Context) {
|
||||||
@@ -28,7 +30,7 @@ func listRegions(c *gin.Context) {
|
|||||||
// @Summary 区域订阅列表
|
// @Summary 区域订阅列表
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} service.RegionSubscriptionView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/region-subscriptions [get]
|
// @Router /api/v1/oci-configs/{id}/region-subscriptions [get]
|
||||||
func (h *ociConfigHandler) regionSubscriptions(c *gin.Context) {
|
func (h *ociConfigHandler) regionSubscriptions(c *gin.Context) {
|
||||||
@@ -52,7 +54,7 @@ type subscribeRegionRequest struct {
|
|||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param body body subscribeRegionRequest true "请求体"
|
// @Param body body subscribeRegionRequest true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} service.RegionSubscriptionView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/region-subscriptions [post]
|
// @Router /api/v1/oci-configs/{id}/region-subscriptions [post]
|
||||||
func (h *ociConfigHandler) subscribeRegion(c *gin.Context) {
|
func (h *ociConfigHandler) subscribeRegion(c *gin.Context) {
|
||||||
@@ -76,7 +78,7 @@ func (h *ociConfigHandler) subscribeRegion(c *gin.Context) {
|
|||||||
// @Summary 服务限额查询
|
// @Summary 服务限额查询
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.LimitValue
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/limits [get]
|
// @Router /api/v1/oci-configs/{id}/limits [get]
|
||||||
func (h *ociConfigHandler) limits(c *gin.Context) {
|
func (h *ociConfigHandler) limits(c *gin.Context) {
|
||||||
|
|||||||
@@ -57,6 +57,13 @@ func (nullClient) GetSubscription(context.Context, oci.Credentials, string) (oci
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newTestRouter(t *testing.T) (*gin.Engine, *service.AuthService, *service.SystemLogService) {
|
func newTestRouter(t *testing.T) (*gin.Engine, *service.AuthService, *service.SystemLogService) {
|
||||||
|
t.Helper()
|
||||||
|
r, auth, systemLogs, _ := newTestRouterDB(t)
|
||||||
|
return r, auth, systemLogs
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestRouterDB 额外回传 db 句柄,供需要直插数据(如网关模型缓存)的测试使用。
|
||||||
|
func newTestRouterDB(t *testing.T) (*gin.Engine, *service.AuthService, *service.SystemLogService, *gorm.DB) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||||
@@ -90,7 +97,7 @@ func newTestRouter(t *testing.T) (*gin.Engine, *service.AuthService, *service.Sy
|
|||||||
logEvents := service.NewLogEventService(db)
|
logEvents := service.NewLogEventService(db)
|
||||||
oauth := service.NewOAuthService(db, settings, auth)
|
oauth := service.NewOAuthService(db, settings, auth)
|
||||||
r := NewRouter(auth, oauth, ociConfigs, tasks, service.NewConsoleService(ociConfigs), settings, notifier, systemLogs, logEvents, service.NewProxyService(db, cipher), service.NewAiGatewayService(db, ociConfigs, nullClient{}))
|
r := NewRouter(auth, oauth, ociConfigs, tasks, service.NewConsoleService(ociConfigs), settings, notifier, systemLogs, logEvents, service.NewProxyService(db, cipher), service.NewAiGatewayService(db, ociConfigs, nullClient{}))
|
||||||
return r, auth, systemLogs
|
return r, auth, systemLogs, db
|
||||||
}
|
}
|
||||||
|
|
||||||
func doRequest(t *testing.T, r *gin.Engine, method, path, token, body string) *httptest.ResponseRecorder {
|
func doRequest(t *testing.T, r *gin.Engine, method, path, token, body string) *httptest.ResponseRecorder {
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ func registerAiGateway(r *gin.Engine, aiGateway *service.AiGatewayService) {
|
|||||||
ai.POST("/responses", aih.responses)
|
ai.POST("/responses", aih.responses)
|
||||||
ai.POST("/messages", aih.messages)
|
ai.POST("/messages", aih.messages)
|
||||||
ai.POST("/embeddings", aih.embeddings)
|
ai.POST("/embeddings", aih.embeddings)
|
||||||
|
ai.POST("/audio/speech", aih.audioSpeech)
|
||||||
|
ai.POST("/tts", aih.tts)
|
||||||
|
ai.POST("/rerank", aih.rerank)
|
||||||
|
ai.POST("/moderations", aih.moderations)
|
||||||
ai.GET("/models", aih.listModels)
|
ai.GET("/models", aih.listModels)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +36,15 @@ func registerAiAdmin(secured *gin.RouterGroup, aiGateway *service.AiGatewayServi
|
|||||||
secured.DELETE("/ai-channels/:id", aiadmin.deleteChannel)
|
secured.DELETE("/ai-channels/:id", aiadmin.deleteChannel)
|
||||||
secured.POST("/ai-channels/:id/probe", aiadmin.probeChannel)
|
secured.POST("/ai-channels/:id/probe", aiadmin.probeChannel)
|
||||||
secured.POST("/ai-channels/:id/sync-models", aiadmin.syncChannelModels)
|
secured.POST("/ai-channels/:id/sync-models", aiadmin.syncChannelModels)
|
||||||
|
secured.GET("/ai-channels/:id/models", aiadmin.listChannelModels)
|
||||||
|
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.PUT("/ai-settings", aiadmin.updateAiSettings)
|
||||||
|
secured.GET("/ai-blacklist", aiadmin.listBlacklist)
|
||||||
|
secured.POST("/ai-blacklist", aiadmin.addBlacklist)
|
||||||
|
secured.DELETE("/ai-blacklist/:id", aiadmin.removeBlacklist)
|
||||||
secured.GET("/ai-logs", aiadmin.listLogs)
|
secured.GET("/ai-logs", aiadmin.listLogs)
|
||||||
secured.GET("/ai-content-logs", aiadmin.listContentLogs)
|
secured.GET("/ai-content-logs", aiadmin.listContentLogs)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ func registerAuthPublic(v1 *gin.RouterGroup, auth *service.AuthService, oauth *s
|
|||||||
v1.POST("/auth/login", ah.login)
|
v1.POST("/auth/login", ah.login)
|
||||||
|
|
||||||
// 外部身份登录:provider 列表 / 授权跳转(bind 模式 handler 内校验 JWT)/ 回调
|
// 外部身份登录:provider 列表 / 授权跳转(bind 模式 handler 内校验 JWT)/ 回调
|
||||||
ax := &authxHandler{auth: auth, oauth: oauth}
|
ax := &authxHandler{auth: auth, oauth: oauth, logs: systemLogs}
|
||||||
v1.GET("/auth/oauth/providers", ax.oauthProviders)
|
v1.GET("/auth/oauth/providers", ax.oauthProviders)
|
||||||
v1.GET("/auth/oauth/:provider/authorize", ax.oauthAuthorize)
|
v1.GET("/auth/oauth/:provider/authorize", ax.oauthAuthorize)
|
||||||
v1.GET("/auth/oauth/:provider/callback", ax.oauthCallback)
|
v1.GET("/auth/oauth/:provider/callback", ax.oauthCallback)
|
||||||
|
|||||||
@@ -35,4 +35,5 @@ func registerSettings(secured *gin.RouterGroup, settings *service.SettingService
|
|||||||
secured.PUT("/proxies/:id", px.update)
|
secured.PUT("/proxies/:id", px.update)
|
||||||
secured.DELETE("/proxies/:id", px.remove)
|
secured.DELETE("/proxies/:id", px.remove)
|
||||||
secured.POST("/proxies/:id/probe", px.probe)
|
secured.POST("/proxies/:id/probe", px.probe)
|
||||||
|
secured.PUT("/proxies/:id/tenants", px.setTenants)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,6 @@ func registerTasksAndLogs(secured *gin.RouterGroup, tasks *service.TaskService,
|
|||||||
|
|
||||||
le := &logEventHandler{svc: logEvents}
|
le := &logEventHandler{svc: logEvents}
|
||||||
secured.GET("/log-events", le.list)
|
secured.GET("/log-events", le.list)
|
||||||
secured.GET("/log-events/alert-rules", le.listAlertRules)
|
|
||||||
secured.POST("/log-events/alert-rules", le.createAlertRule)
|
|
||||||
secured.PUT("/log-events/alert-rules/:ruleId", le.updateAlertRule)
|
|
||||||
secured.DELETE("/log-events/alert-rules/:ruleId", le.deleteAlertRule)
|
|
||||||
secured.GET("/oci-configs/:id/log-webhook", le.getWebhook)
|
secured.GET("/oci-configs/:id/log-webhook", le.getWebhook)
|
||||||
secured.POST("/oci-configs/:id/log-webhook", le.ensureWebhook)
|
secured.POST("/oci-configs/:id/log-webhook", le.ensureWebhook)
|
||||||
secured.DELETE("/oci-configs/:id/log-webhook", le.revokeWebhook)
|
secured.DELETE("/oci-configs/:id/log-webhook", le.revokeWebhook)
|
||||||
|
|||||||
+11
-11
@@ -19,7 +19,7 @@ type settingsHandler struct {
|
|||||||
//
|
//
|
||||||
// @Summary 返回脱敏后的 Telegram 配置,绝不回 token 明文
|
// @Summary 返回脱敏后的 Telegram 配置,绝不回 token 明文
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.TelegramView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/settings/telegram [get]
|
// @Router /api/v1/settings/telegram [get]
|
||||||
func (h *settingsHandler) getTelegram(c *gin.Context) {
|
func (h *settingsHandler) getTelegram(c *gin.Context) {
|
||||||
@@ -44,7 +44,7 @@ type updateTelegramRequest struct {
|
|||||||
// @Summary 保存配置并返回最新脱敏视图
|
// @Summary 保存配置并返回最新脱敏视图
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Param body body updateTelegramRequest true "请求体"
|
// @Param body body updateTelegramRequest true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.TelegramView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/settings/telegram [put]
|
// @Router /api/v1/settings/telegram [put]
|
||||||
func (h *settingsHandler) updateTelegram(c *gin.Context) {
|
func (h *settingsHandler) updateTelegram(c *gin.Context) {
|
||||||
@@ -84,7 +84,7 @@ func (h *settingsHandler) testTelegram(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// @Summary 返回全部通知渠道的脱敏视图
|
// @Summary 返回全部通知渠道的脱敏视图
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} itemsResponse[service.NotifyChannelView]
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/settings/notify-channels [get]
|
// @Router /api/v1/settings/notify-channels [get]
|
||||||
func (h *settingsHandler) listNotifyChannels(c *gin.Context) {
|
func (h *settingsHandler) listNotifyChannels(c *gin.Context) {
|
||||||
@@ -119,7 +119,7 @@ type updateNotifyChannelRequest struct {
|
|||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Param type path string true "渠道类型(webhook/ntfy/bark/smtp)"
|
// @Param type path string true "渠道类型(webhook/ntfy/bark/smtp)"
|
||||||
// @Param body body updateNotifyChannelRequest true "请求体"
|
// @Param body body updateNotifyChannelRequest true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} itemsResponse[service.NotifyChannelView]
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/settings/notify-channels/{type} [put]
|
// @Router /api/v1/settings/notify-channels/{type} [put]
|
||||||
func (h *settingsHandler) updateNotifyChannel(c *gin.Context) {
|
func (h *settingsHandler) updateNotifyChannel(c *gin.Context) {
|
||||||
@@ -160,7 +160,7 @@ func (h *settingsHandler) testNotifyChannel(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// @Summary 返回全部通知事件开关
|
// @Summary 返回全部通知事件开关
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.NotifyEventsView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/settings/notify-events [get]
|
// @Router /api/v1/settings/notify-events [get]
|
||||||
func (h *settingsHandler) getNotifyEvents(c *gin.Context) {
|
func (h *settingsHandler) getNotifyEvents(c *gin.Context) {
|
||||||
@@ -178,7 +178,7 @@ func (h *settingsHandler) getNotifyEvents(c *gin.Context) {
|
|||||||
// @Summary 全量保存五个事件开关并返回最新值
|
// @Summary 全量保存五个事件开关并返回最新值
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Param body body service.NotifyEventsView true "请求体"
|
// @Param body body service.NotifyEventsView true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.NotifyEventsView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/settings/notify-events [put]
|
// @Router /api/v1/settings/notify-events [put]
|
||||||
func (h *settingsHandler) updateNotifyEvents(c *gin.Context) {
|
func (h *settingsHandler) updateNotifyEvents(c *gin.Context) {
|
||||||
@@ -198,7 +198,7 @@ func (h *settingsHandler) updateNotifyEvents(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// @Summary 返回全部通知模板
|
// @Summary 返回全部通知模板
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} itemsResponse[service.NotifyTemplateView]
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/settings/notify-templates [get]
|
// @Router /api/v1/settings/notify-templates [get]
|
||||||
func (h *settingsHandler) listNotifyTemplates(c *gin.Context) {
|
func (h *settingsHandler) listNotifyTemplates(c *gin.Context) {
|
||||||
@@ -263,7 +263,7 @@ func (h *settingsHandler) testNotifyTemplate(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// @Summary 返回任务行为设置
|
// @Summary 返回任务行为设置
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.TaskSettingsView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/settings/task [get]
|
// @Router /api/v1/settings/task [get]
|
||||||
func (h *settingsHandler) getTaskSettings(c *gin.Context) {
|
func (h *settingsHandler) getTaskSettings(c *gin.Context) {
|
||||||
@@ -280,7 +280,7 @@ func (h *settingsHandler) getTaskSettings(c *gin.Context) {
|
|||||||
// @Summary 保存任务行为设置并返回最新值
|
// @Summary 保存任务行为设置并返回最新值
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Param body body service.TaskSettingsView true "请求体"
|
// @Param body body service.TaskSettingsView true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.TaskSettingsView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/settings/task [put]
|
// @Router /api/v1/settings/task [put]
|
||||||
func (h *settingsHandler) updateTaskSettings(c *gin.Context) {
|
func (h *settingsHandler) updateTaskSettings(c *gin.Context) {
|
||||||
@@ -304,7 +304,7 @@ func (h *settingsHandler) updateTaskSettings(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// @Summary 返回安全设置
|
// @Summary 返回安全设置
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.SecuritySettings
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/settings/security [get]
|
// @Router /api/v1/settings/security [get]
|
||||||
func (h *settingsHandler) getSecurity(c *gin.Context) {
|
func (h *settingsHandler) getSecurity(c *gin.Context) {
|
||||||
@@ -321,7 +321,7 @@ func (h *settingsHandler) getSecurity(c *gin.Context) {
|
|||||||
// @Summary 保存安全设置并返回最新值
|
// @Summary 保存安全设置并返回最新值
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Param body body service.SecuritySettings true "请求体"
|
// @Param body body service.SecuritySettings true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} service.SecuritySettings
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/settings/security [put]
|
// @Router /api/v1/settings/security [put]
|
||||||
func (h *settingsHandler) updateSecurity(c *gin.Context) {
|
func (h *settingsHandler) updateSecurity(c *gin.Context) {
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
_ "oci-portal/internal/oci" // swagger 注解引用
|
||||||
)
|
)
|
||||||
|
|
||||||
// @Summary 订阅列表
|
// @Summary 订阅列表
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.SubscriptionInfo
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/subscriptions [get]
|
// @Router /api/v1/oci-configs/{id}/subscriptions [get]
|
||||||
func (h *ociConfigHandler) subscriptions(c *gin.Context) {
|
func (h *ociConfigHandler) subscriptions(c *gin.Context) {
|
||||||
@@ -29,7 +31,7 @@ func (h *ociConfigHandler) subscriptions(c *gin.Context) {
|
|||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param subscriptionId path string true "subscriptionId"
|
// @Param subscriptionId path string true "subscriptionId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.SubscriptionDetail
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/subscriptions/{subscriptionId} [get]
|
// @Router /api/v1/oci-configs/{id}/subscriptions/{subscriptionId} [get]
|
||||||
func (h *ociConfigHandler) subscriptionDetail(c *gin.Context) {
|
func (h *ociConfigHandler) subscriptionDetail(c *gin.Context) {
|
||||||
@@ -48,7 +50,7 @@ func (h *ociConfigHandler) subscriptionDetail(c *gin.Context) {
|
|||||||
// @Summary 限额服务列表
|
// @Summary 限额服务列表
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.LimitService
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/limits/services [get]
|
// @Router /api/v1/oci-configs/{id}/limits/services [get]
|
||||||
func (h *ociConfigHandler) limitServices(c *gin.Context) {
|
func (h *ociConfigHandler) limitServices(c *gin.Context) {
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 本文件的类型仅供 swagger 文档渲染(@Success/@Failure 注解引用),
|
||||||
|
// 运行时代码不使用;字段与各 handler 返回的 gin.H 外壳保持一致。
|
||||||
|
|
||||||
|
// errorResponse 是统一错误响应外壳。
|
||||||
|
type errorResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// itemsResponse 是 {"items": [...]} 列表外壳。
|
||||||
|
type itemsResponse[T any] struct {
|
||||||
|
Items []T `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// pagedResponse 是 {"items": [...], "total": n} 分页外壳。
|
||||||
|
type pagedResponse[T any] struct {
|
||||||
|
Items []T `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// itemResponse 是 {"item": {...}} 单项外壳。
|
||||||
|
type itemResponse[T any] struct {
|
||||||
|
Item T `json:"item"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// triggerResponse 是任务触发受理响应。
|
||||||
|
type triggerResponse struct {
|
||||||
|
Triggered bool `json:"triggered"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenResponse 是登录 / 换发令牌响应。
|
||||||
|
type tokenResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// totpRequiredResponse 是密码通过但需两步验证码的 428 响应。
|
||||||
|
type totpRequiredResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
TotpRequired bool `json:"totpRequired"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// enabledResponse 是布尔开关查询响应。
|
||||||
|
type enabledResponse struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// totpSetupResponse 是 TOTP 初始化响应。
|
||||||
|
type totpSetupResponse struct {
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
OtpauthUri string `json:"otpauthUri"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// credentialsResponse 是当前登录账号信息。
|
||||||
|
type credentialsResponse struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
PasswordLoginDisabled bool `json:"passwordLoginDisabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// oauthProvidersResponse 是外部登录 provider 列表。
|
||||||
|
type oauthProvidersResponse struct {
|
||||||
|
Providers []service.ProviderInfo `json:"providers"`
|
||||||
|
PasswordLoginDisabled bool `json:"passwordLoginDisabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// urlResponse 是跳转地址响应。
|
||||||
|
type urlResponse struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// aboutResponse 是「设置 · 关于」页构建与运行信息。
|
||||||
|
type aboutResponse struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
BuildTime string `json:"buildTime"`
|
||||||
|
GoVersion string `json:"goVersion"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
StartedAt string `json:"startedAt"`
|
||||||
|
UptimeSeconds int64 `json:"uptimeSeconds"`
|
||||||
|
Resources aboutResources `json:"resources"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// aboutResources 是进程资源占用快照。
|
||||||
|
type aboutResources struct {
|
||||||
|
CpuAvgPercent float64 `json:"cpuAvgPercent"`
|
||||||
|
NumCpu int `json:"numCpu"`
|
||||||
|
Goroutines int `json:"goroutines"`
|
||||||
|
MemHeapBytes uint64 `json:"memHeapBytes"`
|
||||||
|
MemSysBytes uint64 `json:"memSysBytes"`
|
||||||
|
DbEngine string `json:"dbEngine"`
|
||||||
|
DbBytes int64 `json:"dbBytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicIpResponse 是换绑公网 IP 结果。
|
||||||
|
type publicIpResponse struct {
|
||||||
|
PublicIp string `json:"publicIp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ipv6AddressResponse 是实例新增 IPv6 结果。
|
||||||
|
type ipv6AddressResponse struct {
|
||||||
|
Ipv6Address string `json:"ipv6Address"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// addressResponse 是 VNIC 新增 IPv6 结果。
|
||||||
|
type addressResponse struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ipv6StepsResponse 是 VCN 开启 IPv6 的分步结果。
|
||||||
|
type ipv6StepsResponse struct {
|
||||||
|
Steps []oci.IPv6Step `json:"steps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// configChangesResponse 是租户配置校验 / 更新结果(config 与字段变更对照)。
|
||||||
|
type configChangesResponse struct {
|
||||||
|
Config *model.OciConfig `json:"config"`
|
||||||
|
Changes service.Changes `json:"changes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// aiKeyCreateResponse 是 AI 密钥创建结果;key 为明文,仅本次返回。
|
||||||
|
type aiKeyCreateResponse struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Item model.AiKey `json:"item"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// usedByResponse 是代理关联租户结果(关联数)。
|
||||||
|
type usedByResponse struct {
|
||||||
|
UsedBy int64 `json:"usedBy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawDetailResponse 是审计事件原文响应。
|
||||||
|
type rawDetailResponse struct {
|
||||||
|
Raw json.RawMessage `json:"raw"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// passwordResponse 是租户用户重置密码结果(一次性明文)。
|
||||||
|
type passwordResponse struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// deletedTotpResponse 是清除 MFA 设备结果。
|
||||||
|
type deletedTotpResponse struct {
|
||||||
|
DeletedTotpDevices int `json:"deletedTotpDevices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// deletedApiKeysResponse 是清理用户 API Key 结果。
|
||||||
|
type deletedApiKeysResponse struct {
|
||||||
|
DeletedApiKeys int `json:"deletedApiKeys"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// createInstancesResponse 是批量创建实例结果;部分成功仍 201,errors 为逐台失败信息。
|
||||||
|
type createInstancesResponse struct {
|
||||||
|
Instances []oci.Instance `json:"instances"`
|
||||||
|
Errors []string `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// logWebhookStatusResponse 是日志回传 webhook 状态。
|
||||||
|
type logWebhookStatusResponse struct {
|
||||||
|
Exists bool `json:"exists"`
|
||||||
|
Webhook *service.LogWebhookInfo `json:"webhook,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// webConsoleSessionResponse 是网页控制台会话创建结果。
|
||||||
|
type webConsoleSessionResponse struct {
|
||||||
|
SessionId string `json:"sessionId"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
_ "oci-portal/internal/model" // swagger 注解引用
|
||||||
"oci-portal/internal/service"
|
"oci-portal/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ type systemLogHandler struct {
|
|||||||
//
|
//
|
||||||
// @Summary 系统操作日志列表
|
// @Summary 系统操作日志列表
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Success 200 {object} map[string]any "items 与 total"
|
// @Success 200 {object} pagedResponse[model.SystemLog] "items 与 total"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/system-logs [get]
|
// @Router /api/v1/system-logs [get]
|
||||||
func (h *systemLogHandler) list(c *gin.Context) {
|
func (h *systemLogHandler) list(c *gin.Context) {
|
||||||
|
|||||||
+16
-10
@@ -2,11 +2,13 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
_ "oci-portal/internal/model" // swagger 注解引用
|
||||||
"oci-portal/internal/service"
|
"oci-portal/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ type updateTaskRequest struct {
|
|||||||
// @Summary 创建任务
|
// @Summary 创建任务
|
||||||
// @Tags 任务与日志回传
|
// @Tags 任务与日志回传
|
||||||
// @Param body body createTaskRequest true "任务定义(类型/cron/payload)"
|
// @Param body body createTaskRequest true "任务定义(类型/cron/payload)"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} model.Task
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/tasks [post]
|
// @Router /api/v1/tasks [post]
|
||||||
func (h *taskHandler) create(c *gin.Context) {
|
func (h *taskHandler) create(c *gin.Context) {
|
||||||
@@ -56,7 +58,7 @@ func (h *taskHandler) create(c *gin.Context) {
|
|||||||
|
|
||||||
// @Summary 任务列表
|
// @Summary 任务列表
|
||||||
// @Tags 任务与日志回传
|
// @Tags 任务与日志回传
|
||||||
// @Success 200 {array} map[string]any
|
// @Success 200 {array} model.Task
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/tasks [get]
|
// @Router /api/v1/tasks [get]
|
||||||
func (h *taskHandler) list(c *gin.Context) {
|
func (h *taskHandler) list(c *gin.Context) {
|
||||||
@@ -71,7 +73,7 @@ func (h *taskHandler) list(c *gin.Context) {
|
|||||||
// @Summary 任务详情
|
// @Summary 任务详情
|
||||||
// @Tags 任务与日志回传
|
// @Tags 任务与日志回传
|
||||||
// @Param id path int true "任务 ID"
|
// @Param id path int true "任务 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} model.Task
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/tasks/{id} [get]
|
// @Router /api/v1/tasks/{id} [get]
|
||||||
func (h *taskHandler) get(c *gin.Context) {
|
func (h *taskHandler) get(c *gin.Context) {
|
||||||
@@ -91,7 +93,7 @@ func (h *taskHandler) get(c *gin.Context) {
|
|||||||
// @Tags 任务与日志回传
|
// @Tags 任务与日志回传
|
||||||
// @Param id path int true "任务 ID"
|
// @Param id path int true "任务 ID"
|
||||||
// @Param body body updateTaskRequest true "可更新字段"
|
// @Param body body updateTaskRequest true "可更新字段"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} model.Task
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/tasks/{id} [put]
|
// @Router /api/v1/tasks/{id} [put]
|
||||||
func (h *taskHandler) update(c *gin.Context) {
|
func (h *taskHandler) update(c *gin.Context) {
|
||||||
@@ -138,7 +140,7 @@ func (h *taskHandler) remove(c *gin.Context) {
|
|||||||
// @Summary 任务执行日志
|
// @Summary 任务执行日志
|
||||||
// @Tags 任务与日志回传
|
// @Tags 任务与日志回传
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} model.TaskLog
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/tasks/{id}/logs [get]
|
// @Router /api/v1/tasks/{id}/logs [get]
|
||||||
func (h *taskHandler) logs(c *gin.Context) {
|
func (h *taskHandler) logs(c *gin.Context) {
|
||||||
@@ -155,10 +157,11 @@ func (h *taskHandler) logs(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, logs)
|
c.JSON(http.StatusOK, logs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Summary 立即执行任务
|
// @Summary 立即执行任务(异步触发,结果经任务日志轮询获取)
|
||||||
// @Tags 任务与日志回传
|
// @Tags 任务与日志回传
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 202 {object} triggerResponse
|
||||||
|
// @Failure 409 {object} errorResponse "任务正在执行中"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/tasks/{id}/run [post]
|
// @Router /api/v1/tasks/{id}/run [post]
|
||||||
func (h *taskHandler) run(c *gin.Context) {
|
func (h *taskHandler) run(c *gin.Context) {
|
||||||
@@ -166,10 +169,13 @@ func (h *taskHandler) run(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
entry, err := h.svc.RunTaskNow(c.Request.Context(), id)
|
if err := h.svc.TriggerTask(c.Request.Context(), id); err != nil {
|
||||||
if err != nil {
|
if errors.Is(err, service.ErrTaskRunning) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, entry)
|
c.JSON(http.StatusAccepted, gin.H{"triggered": true})
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-20
@@ -18,7 +18,7 @@ import (
|
|||||||
// @Tags 计算
|
// @Tags 计算
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.InstanceTraffic
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/traffic [get]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/traffic [get]
|
||||||
func (h *ociConfigHandler) instanceTraffic(c *gin.Context) {
|
func (h *ociConfigHandler) instanceTraffic(c *gin.Context) {
|
||||||
@@ -38,7 +38,7 @@ func (h *ociConfigHandler) instanceTraffic(c *gin.Context) {
|
|||||||
// @Summary 配置成本快照
|
// @Summary 配置成本快照
|
||||||
// @Tags 成本
|
// @Tags 成本
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.CostItem
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/costs [get]
|
// @Router /api/v1/oci-configs/{id}/costs [get]
|
||||||
func (h *ociConfigHandler) costs(c *gin.Context) {
|
func (h *ociConfigHandler) costs(c *gin.Context) {
|
||||||
@@ -68,14 +68,16 @@ 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"
|
||||||
// @Success 200 {object} map[string]any
|
// @Param q query string false "检索关键字(服务端全文匹配,支持 * 通配;仅首查生效)"
|
||||||
|
// @Success 200 {object} service.AuditEventsView
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/audit-events [get]
|
// @Router /api/v1/oci-configs/{id}/audit-events [get]
|
||||||
func (h *ociConfigHandler) getAuditEvents(c *gin.Context) {
|
func (h *ociConfigHandler) getAuditEvents(c *gin.Context) {
|
||||||
@@ -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()})
|
||||||
@@ -103,7 +105,7 @@ func (h *ociConfigHandler) getAuditEvents(c *gin.Context) {
|
|||||||
// @Summary 按 eventId 取回原始事件 JSON:缓存命中秒开,
|
// @Summary 按 eventId 取回原始事件 JSON:缓存命中秒开,
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} rawDetailResponse "raw 为事件原文 JSON"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/audit-events/detail [get]
|
// @Router /api/v1/oci-configs/{id}/audit-events/detail [get]
|
||||||
func (h *ociConfigHandler) getAuditEventDetail(c *gin.Context) {
|
func (h *ociConfigHandler) getAuditEventDetail(c *gin.Context) {
|
||||||
@@ -145,7 +147,7 @@ type createTenantUserRequest struct {
|
|||||||
// @Summary 租户身份域列表
|
// @Summary 租户身份域列表
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.IdentityDomain
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/domains [get]
|
// @Router /api/v1/oci-configs/{id}/domains [get]
|
||||||
func (h *ociConfigHandler) listIdentityDomains(c *gin.Context) {
|
func (h *ociConfigHandler) listIdentityDomains(c *gin.Context) {
|
||||||
@@ -165,7 +167,7 @@ func (h *ociConfigHandler) listIdentityDomains(c *gin.Context) {
|
|||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.TenantUser
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/users [get]
|
// @Router /api/v1/oci-configs/{id}/users [get]
|
||||||
func (h *ociConfigHandler) listTenantUsers(c *gin.Context) {
|
func (h *ociConfigHandler) listTenantUsers(c *gin.Context) {
|
||||||
@@ -186,7 +188,7 @@ func (h *ociConfigHandler) listTenantUsers(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param userId path string true "userId"
|
// @Param userId path string true "userId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.TenantUserDetail
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/users/{userId} [get]
|
// @Router /api/v1/oci-configs/{id}/users/{userId} [get]
|
||||||
func (h *ociConfigHandler) getTenantUserDetail(c *gin.Context) {
|
func (h *ociConfigHandler) getTenantUserDetail(c *gin.Context) {
|
||||||
@@ -207,7 +209,7 @@ func (h *ociConfigHandler) getTenantUserDetail(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param body body createTenantUserRequest true "请求体"
|
// @Param body body createTenantUserRequest true "请求体"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} oci.TenantUser
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/users [post]
|
// @Router /api/v1/oci-configs/{id}/users [post]
|
||||||
func (h *ociConfigHandler) createTenantUser(c *gin.Context) {
|
func (h *ociConfigHandler) createTenantUser(c *gin.Context) {
|
||||||
@@ -253,7 +255,7 @@ type updateTenantUserRequest struct {
|
|||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param userId path string true "userId"
|
// @Param userId path string true "userId"
|
||||||
// @Param body body updateTenantUserRequest true "请求体"
|
// @Param body body updateTenantUserRequest true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.TenantUser
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/users/{userId} [put]
|
// @Router /api/v1/oci-configs/{id}/users/{userId} [put]
|
||||||
func (h *ociConfigHandler) updateTenantUser(c *gin.Context) {
|
func (h *ociConfigHandler) updateTenantUser(c *gin.Context) {
|
||||||
@@ -306,7 +308,7 @@ func (h *ociConfigHandler) deleteTenantUser(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param userId path string true "userId"
|
// @Param userId path string true "userId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} passwordResponse "一次性明文密码"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/users/{userId}/reset-password [post]
|
// @Router /api/v1/oci-configs/{id}/users/{userId}/reset-password [post]
|
||||||
func (h *ociConfigHandler) resetTenantUserPassword(c *gin.Context) {
|
func (h *ociConfigHandler) resetTenantUserPassword(c *gin.Context) {
|
||||||
@@ -327,7 +329,7 @@ func (h *ociConfigHandler) resetTenantUserPassword(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param userId path string true "userId"
|
// @Param userId path string true "userId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} deletedTotpResponse
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/users/{userId}/mfa-devices [delete]
|
// @Router /api/v1/oci-configs/{id}/users/{userId}/mfa-devices [delete]
|
||||||
func (h *ociConfigHandler) deleteTenantUserMfa(c *gin.Context) {
|
func (h *ociConfigHandler) deleteTenantUserMfa(c *gin.Context) {
|
||||||
@@ -347,7 +349,7 @@ func (h *ociConfigHandler) deleteTenantUserMfa(c *gin.Context) {
|
|||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param userId path string true "userId"
|
// @Param userId path string true "userId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} deletedApiKeysResponse
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/users/{userId}/api-keys [delete]
|
// @Router /api/v1/oci-configs/{id}/users/{userId}/api-keys [delete]
|
||||||
func (h *ociConfigHandler) deleteTenantUserApiKeys(c *gin.Context) {
|
func (h *ociConfigHandler) deleteTenantUserApiKeys(c *gin.Context) {
|
||||||
@@ -370,7 +372,7 @@ func (h *ociConfigHandler) deleteTenantUserApiKeys(c *gin.Context) {
|
|||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.NotificationRecipients
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/notification-recipients [get]
|
// @Router /api/v1/oci-configs/{id}/notification-recipients [get]
|
||||||
func (h *ociConfigHandler) getNotificationRecipients(c *gin.Context) {
|
func (h *ociConfigHandler) getNotificationRecipients(c *gin.Context) {
|
||||||
@@ -391,7 +393,7 @@ func (h *ociConfigHandler) getNotificationRecipients(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param body body object true "请求体(见接口说明)"
|
// @Param body body object true "请求体(见接口说明)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.NotificationRecipients
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/notification-recipients [put]
|
// @Router /api/v1/oci-configs/{id}/notification-recipients [put]
|
||||||
func (h *ociConfigHandler) updateNotificationRecipients(c *gin.Context) {
|
func (h *ociConfigHandler) updateNotificationRecipients(c *gin.Context) {
|
||||||
@@ -418,7 +420,7 @@ func (h *ociConfigHandler) updateNotificationRecipients(c *gin.Context) {
|
|||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {array} oci.PasswordPolicyInfo
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/password-policies [get]
|
// @Router /api/v1/oci-configs/{id}/password-policies [get]
|
||||||
func (h *ociConfigHandler) listPasswordPolicies(c *gin.Context) {
|
func (h *ociConfigHandler) listPasswordPolicies(c *gin.Context) {
|
||||||
@@ -440,7 +442,7 @@ func (h *ociConfigHandler) listPasswordPolicies(c *gin.Context) {
|
|||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param policyId path string true "policyId"
|
// @Param policyId path string true "policyId"
|
||||||
// @Param body body object true "请求体(见接口说明)"
|
// @Param body body object true "请求体(见接口说明)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.PasswordPolicyInfo
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/password-policies/{policyId} [put]
|
// @Router /api/v1/oci-configs/{id}/password-policies/{policyId} [put]
|
||||||
func (h *ociConfigHandler) updatePasswordPolicy(c *gin.Context) {
|
func (h *ociConfigHandler) updatePasswordPolicy(c *gin.Context) {
|
||||||
@@ -473,7 +475,7 @@ func (h *ociConfigHandler) updatePasswordPolicy(c *gin.Context) {
|
|||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.IdentitySettingInfo
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/identity-settings [get]
|
// @Router /api/v1/oci-configs/{id}/identity-settings [get]
|
||||||
func (h *ociConfigHandler) getIdentitySetting(c *gin.Context) {
|
func (h *ociConfigHandler) getIdentitySetting(c *gin.Context) {
|
||||||
@@ -494,7 +496,7 @@ func (h *ociConfigHandler) getIdentitySetting(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param body body object true "请求体(见接口说明)"
|
// @Param body body object true "请求体(见接口说明)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} oci.IdentitySettingInfo
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/identity-settings [put]
|
// @Router /api/v1/oci-configs/{id}/identity-settings [put]
|
||||||
func (h *ociConfigHandler) updateIdentitySetting(c *gin.Context) {
|
func (h *ociConfigHandler) updateIdentitySetting(c *gin.Context) {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ type createConsoleSessionRequest struct {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "实例 OCID"
|
// @Param instanceId path string true "实例 OCID"
|
||||||
// @Param body body object true "{type: serial|vnc}"
|
// @Param body body object true "{type: serial|vnc}"
|
||||||
// @Success 200 {object} map[string]any "sessionId 与 wsPath"
|
// @Success 201 {object} webConsoleSessionResponse "sessionId 与 type"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/console-sessions [post]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/console-sessions [post]
|
||||||
func (h *consoleHandler) create(c *gin.Context) {
|
func (h *consoleHandler) create(c *gin.Context) {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ type webhookHandler struct {
|
|||||||
// @Param secret path string true "面板生成的回传密钥(鉴权凭据)"
|
// @Param secret path string true "面板生成的回传密钥(鉴权凭据)"
|
||||||
// @Param body body object true "ONS 消息原文(SubscriptionConfirmation / Notification)"
|
// @Param body body object true "ONS 消息原文(SubscriptionConfirmation / Notification)"
|
||||||
// @Success 200 "已受理"
|
// @Success 200 "已受理"
|
||||||
// @Failure 403 {object} map[string]string "时间戳超窗或证书源非 Oracle 域"
|
// @Failure 403 {object} errorResponse "时间戳超窗或证书源非 Oracle 域"
|
||||||
// @Failure 404 "secret 无效(不暴露端点存在性)"
|
// @Failure 404 "secret 无效(不暴露端点存在性)"
|
||||||
// @Router /api/v1/webhooks/oci-logs/{secret} [post]
|
// @Router /api/v1/webhooks/oci-logs/{secret} [post]
|
||||||
func (h *webhookHandler) handle(c *gin.Context) {
|
func (h *webhookHandler) handle(c *gin.Context) {
|
||||||
|
|||||||
@@ -67,8 +67,8 @@ func autoMigrate(db *gorm.DB) error {
|
|||||||
&model.User{}, &model.UserIdentity{}, &model.OciConfig{}, &model.Task{}, &model.TaskLog{},
|
&model.User{}, &model.UserIdentity{}, &model.OciConfig{}, &model.Task{}, &model.TaskLog{},
|
||||||
&model.CheckSnapshot{}, &model.CostSnapshot{},
|
&model.CheckSnapshot{}, &model.CostSnapshot{},
|
||||||
&model.RegionCache{}, &model.CompartmentCache{}, &model.Setting{},
|
&model.RegionCache{}, &model.CompartmentCache{}, &model.Setting{},
|
||||||
&model.SystemLog{}, &model.LogEvent{}, &model.AlertRule{}, &model.AlertRuleHit{}, &model.Proxy{},
|
&model.SystemLog{}, &model.LogEvent{}, &model.Proxy{},
|
||||||
&model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiCallLog{},
|
&model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiModelBlacklist{}, &model.AiCallLog{},
|
||||||
&model.AiContentLog{},
|
&model.AiContentLog{},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-33
@@ -211,31 +211,6 @@ type LogEvent struct {
|
|||||||
ReceivedAt time.Time `json:"receivedAt"`
|
ReceivedAt time.Time `json:"receivedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlertRule 是回传事件的自定义告警规则;条件间 AND 关系,空条件视为任意。
|
|
||||||
type AlertRule struct {
|
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
|
||||||
Name string `gorm:"size:64" json:"name"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
OciConfigID uint `json:"ociConfigId"` // 0=全部租户
|
|
||||||
EventTypes string `gorm:"size:512" json:"eventTypes"` // 逗号分隔事件短名,空=全部
|
|
||||||
SourceIPs string `gorm:"size:512" json:"sourceIps"` // 逗号分隔 IP/CIDR,空=任意
|
|
||||||
// in:来源命中列表才告警(默认);notin:不在列表才告警(白名单场景)
|
|
||||||
SourceIPMode string `gorm:"size:8" json:"sourceIpMode"`
|
|
||||||
ResourceMatch string `gorm:"size:128" json:"resourceMatch"` // 资源名子串,空=任意
|
|
||||||
Threshold int `json:"threshold"` // 触发阈值,默认 1(即时)
|
|
||||||
WindowMinutes int `json:"windowMinutes"` // 聚合窗口,Threshold>1 时必填
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AlertRuleHit 记录规则命中,供阈值窗口计数;随周期清理删除过期行。
|
|
||||||
type AlertRuleHit struct {
|
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
|
||||||
RuleID uint `gorm:"index" json:"ruleId"`
|
|
||||||
LogEventID uint `json:"logEventId"`
|
|
||||||
HitAt time.Time `gorm:"index" json:"hitAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegionCache 是开启多区域支持的配置缓存的订阅区域(每配置多行,整组覆盖)。
|
// RegionCache 是开启多区域支持的配置缓存的订阅区域(每配置多行,整组覆盖)。
|
||||||
// Status 非 READY(新订阅进行中)时读取接口会实时刷新,直到全部 READY。
|
// Status 非 READY(新订阅进行中)时读取接口会实时刷新,直到全部 READY。
|
||||||
type RegionCache struct {
|
type RegionCache struct {
|
||||||
@@ -261,11 +236,13 @@ type CompartmentCache struct {
|
|||||||
// AiKey 是 AI 网关的访问密钥;明文不落库,仅存 SHA-256 与尾 4 位。
|
// AiKey 是 AI 网关的访问密钥;明文不落库,仅存 SHA-256 与尾 4 位。
|
||||||
// Group 非空时该密钥只在同分组渠道内负载均衡(列名避开 SQL 保留字 group)。
|
// Group 非空时该密钥只在同分组渠道内负载均衡(列名避开 SQL 保留字 group)。
|
||||||
type AiKey struct {
|
type AiKey struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
Name string `gorm:"uniqueIndex;size:64" json:"name"`
|
Name string `gorm:"uniqueIndex;size:64" json:"name"`
|
||||||
KeyHash string `gorm:"uniqueIndex;size:64" json:"-"`
|
KeyHash string `gorm:"uniqueIndex;size:64" json:"-"`
|
||||||
Tail string `gorm:"size:4" json:"tail"`
|
Tail string `gorm:"size:4" json:"tail"`
|
||||||
Group string `gorm:"column:key_group;size:32" json:"group"`
|
Group string `gorm:"column:key_group;size:32" json:"group"`
|
||||||
|
// Models 是模型白名单(精确匹配模型名);空 = 不限,非空时网关仅放行列表内模型
|
||||||
|
Models []string `gorm:"serializer:json;type:text" json:"models"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
LastUsedAt *time.Time `json:"lastUsedAt"`
|
LastUsedAt *time.Time `json:"lastUsedAt"`
|
||||||
// ContentLogUntil 是内容日志开启截止时间;nil = 永关(默认,红线),
|
// ContentLogUntil 是内容日志开启截止时间;nil = 永关(默认,红线),
|
||||||
@@ -291,12 +268,16 @@ type AiChannel struct {
|
|||||||
LastProbeAt *time.Time `json:"lastProbeAt"`
|
LastProbeAt *time.Time `json:"lastProbeAt"`
|
||||||
ProbeStatus string `gorm:"size:16" json:"probeStatus"` // ok / no_service / no_quota / error
|
ProbeStatus string `gorm:"size:16" json:"probeStatus"` // ok / no_service / no_quota / error
|
||||||
ProbeError string `gorm:"size:512" json:"probeError"`
|
ProbeError string `gorm:"size:512" json:"probeError"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
// ProbeModel 是用户测试通过后固定的探测验证模型名;探测时置于候选首位
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
ProbeModel string `gorm:"size:96" json:"probeModel"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
// ModelCount 是渠道模型缓存计数,列表查询回填,不落库
|
||||||
|
ModelCount int64 `gorm:"-" json:"modelCount"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AiModelCache 是渠道区域的可用模型缓存(整渠道覆盖式同步)。
|
// AiModelCache 是渠道区域的可用模型缓存(整渠道覆盖式同步)。
|
||||||
// Capability 为 CHAT / EMBEDDING;存量空串视为 CHAT(加列前只同步对话模型)。
|
// Capability 为 CHAT / EMBEDDING / RERANK / TTS;存量空串视为 CHAT(加列前只同步对话模型)。
|
||||||
type AiModelCache struct {
|
type AiModelCache struct {
|
||||||
ID uint `gorm:"primaryKey" json:"-"`
|
ID uint `gorm:"primaryKey" json:"-"`
|
||||||
ChannelID uint `gorm:"index" json:"channelId"`
|
ChannelID uint `gorm:"index" json:"channelId"`
|
||||||
@@ -311,6 +292,14 @@ type AiModelCache struct {
|
|||||||
RetiredAt *time.Time `json:"retiredAt"`
|
RetiredAt *time.Time `json:"retiredAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AiModelBlacklist 是全局模型黑名单(按模型名):加入即从全部渠道缓存删除该模型,
|
||||||
|
// 同步与探测过滤不再入库;适用于微调基座等实际不可按需调用的模型,由用户手动维护。
|
||||||
|
type AiModelBlacklist struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Name string `gorm:"size:96;uniqueIndex" json:"name"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
// AiCallLog 是网关调用日志:仅元数据与 token 用量,绝不记录 prompt / 响应正文。
|
// AiCallLog 是网关调用日志:仅元数据与 token 用量,绝不记录 prompt / 响应正文。
|
||||||
type AiCallLog struct {
|
type AiCallLog struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
|||||||
+469
-184
@@ -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)
|
||||||
// auditInternalCIDRs 是 OCI 服务内部互调的发起方网段(RFC1918 + CGNAT)。
|
if raw, mErr := json.Marshal(ev); mErr == nil {
|
||||||
var auditInternalCIDRs = func() []*net.IPNet {
|
out.Raw = raw
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
items = append(items, out)
|
||||||
}
|
}
|
||||||
return true
|
return items, deref(resp.OpcNextPage), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// toAuditEvent 把 SDK 审计事件压平为列表 DTO;SDK 字段全为指针,逐层判 nil。
|
// toAuditEvent 把 Audit 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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ func NewCachedClient(inner Client) *CachedClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ckey 组缓存键;租户 OCID 在最前,写失效按前缀一锅端。
|
// ckey 组缓存键;租户 OCID 在最前,写失效按前缀一锅端。
|
||||||
|
// compartment 必须参与键值:列表查询按 EffectiveCompartment 过滤,
|
||||||
|
// 同租户切换区间时若共用键会串到上一个区间的缓存结果。
|
||||||
func ckey(cred Credentials, parts ...string) string {
|
func ckey(cred Credentials, parts ...string) string {
|
||||||
return cred.TenancyOCID + "|" + strings.Join(parts, "|")
|
return cred.TenancyOCID + "|" + cred.CompartmentID + "|" + strings.Join(parts, "|")
|
||||||
}
|
}
|
||||||
|
|
||||||
// bust 写操作成功后失效该租户全部读缓存。
|
// bust 写操作成功后失效该租户全部读缓存。
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ func TestCachedClientHitAndIsolation(t *testing.T) {
|
|||||||
if inner.instCalls != 3 {
|
if inner.instCalls != 3 {
|
||||||
t.Errorf("跨租户/区域回源 %d 次, want 3", inner.instCalls)
|
t.Errorf("跨租户/区域回源 %d 次, want 3", inner.instCalls)
|
||||||
}
|
}
|
||||||
|
// 同租户不同 compartment 各自回源,不得共用缓存
|
||||||
|
inCompartment := testCred("t1")
|
||||||
|
inCompartment.CompartmentID = "ocid1.compartment.a"
|
||||||
|
_, _ = c.ListInstances(ctx, inCompartment, "r1")
|
||||||
|
if inner.instCalls != 4 {
|
||||||
|
t.Errorf("跨 compartment 回源 %d 次, want 4", inner.instCalls)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCachedClientWriteBusts(t *testing.T) {
|
func TestCachedClientWriteBusts(t *testing.T) {
|
||||||
|
|||||||
+13
-2
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -99,10 +100,20 @@ type Client interface {
|
|||||||
AddVnicIpv6(ctx context.Context, cred Credentials, region, vnicID, address string) (string, error)
|
AddVnicIpv6(ctx context.Context, cred Credentials, region, vnicID, address string) (string, error)
|
||||||
// GenAI 网关:区域模型列表、聊天(非流式/流式)、配额探测、文本向量化。
|
// GenAI 网关:区域模型列表、聊天(非流式/流式)、配额探测、文本向量化。
|
||||||
ListGenAiModels(ctx context.Context, cred Credentials, region string) ([]GenAiModel, error)
|
ListGenAiModels(ctx context.Context, cred Credentials, region string) ([]GenAiModel, error)
|
||||||
GenAiChat(ctx context.Context, cred Credentials, region, modelOcid string, ir aiwire.ChatRequest) (*aiwire.ChatResponse, error)
|
|
||||||
GenAiChatStream(ctx context.Context, cred Credentials, region, modelOcid string, ir aiwire.ChatRequest) (GenAiStream, 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 服务端工具通路);
|
||||||
|
// wait 是上游无响应预算(整请求总超时),multi-agent/搜索类模型需远超 SDK 默认 60s。
|
||||||
|
GenAiCompatResponses(ctx context.Context, cred Credentials, region string, body []byte, wait time.Duration) ([]byte, 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(ctx context.Context, cred Credentials, region string, body []byte) ([]byte, string, error)
|
||||||
|
// GenAiRerank 文档重排,返回按相关度排序的下标与得分。
|
||||||
|
GenAiRerank(ctx context.Context, cred Credentials, region, modelOcid, query string, documents []string, topN *int) ([]RerankRank, error)
|
||||||
|
// GenAiApplyGuardrails 对单条文本执行内容审核 / PII / 提示注入检测。
|
||||||
|
GenAiApplyGuardrails(ctx context.Context, cred Credentials, region, text string) (*GuardrailsOutcome, error)
|
||||||
// 控制台连接:创建(VNC/串口连接串)、列出、删除。
|
// 控制台连接:创建(VNC/串口连接串)、列出、删除。
|
||||||
CreateConsoleConnection(ctx context.Context, cred Credentials, region, instanceID, sshPublicKey string) (ConsoleConnection, error)
|
CreateConsoleConnection(ctx context.Context, cred Credentials, region, instanceID, sshPublicKey string) (ConsoleConnection, error)
|
||||||
ListConsoleConnections(ctx context.Context, cred Credentials, region, instanceID string) ([]ConsoleConnection, error)
|
ListConsoleConnections(ctx context.Context, cred Credentials, region, instanceID string) ([]ConsoleConnection, error)
|
||||||
|
|||||||
@@ -76,3 +76,34 @@ func ServiceStatus(err error) (int, bool) {
|
|||||||
}
|
}
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsOnDemandUnsupported 识别「模型在该区域仅为微调基座、不支持按需调用」的 400:
|
||||||
|
// OCI 消息形如 "Not allowed to call finetune base model …, use Endpoint: false"。
|
||||||
|
// ListModels 无字段可事先区分,只能在调用报错时识别并换渠道。
|
||||||
|
func IsOnDemandUnsupported(err error) bool {
|
||||||
|
var svcErr common.ServiceError
|
||||||
|
if !errors.As(err, &svcErr) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return svcErr.GetHTTPStatusCode() == 400 &&
|
||||||
|
strings.Contains(strings.ToLower(svcErr.GetMessage()), "finetune base model")
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEntityNotFound 识别「实体不存在」404(消息 "Entity with key … not found"):
|
||||||
|
// GenAI 对区域内无按需供给的模型 OCID 返回此类 404,属模型级错误;
|
||||||
|
// 鉴权失败的 404 是 NotAuthorizedOrNotFound(消息为 Authorization failed…),不在此列。
|
||||||
|
func IsEntityNotFound(err error) bool {
|
||||||
|
var svcErr common.ServiceError
|
||||||
|
if !errors.As(err, &svcErr) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := strings.ToLower(svcErr.GetMessage())
|
||||||
|
return svcErr.GetHTTPStatusCode() == 404 &&
|
||||||
|
strings.Contains(msg, "entity with key") && strings.Contains(msg, "not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsModelUnavailable 归并「模型×区域不可按需调用」两类报错:微调基座 400 与实体不存在 404;
|
||||||
|
// 命中即应把该 (渠道, 模型) 从池中剔除并换候选/换渠道,而非定论租户配额问题。
|
||||||
|
func IsModelUnavailable(err error) bool {
|
||||||
|
return IsOnDemandUnsupported(err) || IsEntityNotFound(err)
|
||||||
|
}
|
||||||
|
|||||||
@@ -109,3 +109,52 @@ func TestErrorHint(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsOnDemandUnsupported(t *testing.T) {
|
||||||
|
ft := fakeServiceError{status: 400, code: "InvalidParameter",
|
||||||
|
message: "Not allowed to call finetune base model ocid1.generativeaimodel.oc1.eu-frankfurt-1.tpel5q, use Endpoint: false"}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"微调基座 400 命中(含包装链)", fmt.Errorf("genai chat: %w", ft), true},
|
||||||
|
{"同消息但非 400 不命中", fakeServiceError{status: 500, code: "InternalError", message: ft.message}, false},
|
||||||
|
{"普通 400 不命中", fakeServiceError{status: 400, code: "InvalidParameter", message: "bad request"}, false},
|
||||||
|
{"非服务端错误不命中", errors.New("finetune base model"), false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := IsOnDemandUnsupported(tt.err); got != tt.want {
|
||||||
|
t.Errorf("IsOnDemandUnsupported() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsModelUnavailable(t *testing.T) {
|
||||||
|
entity404 := fakeServiceError{status: 404, code: "NotFound",
|
||||||
|
message: "Entity with key ocid1.generativeaimodel.oc1.eu-frankfurt-1.2flsfq not found"}
|
||||||
|
auth404 := fakeServiceError{status: 404, code: "NotAuthorizedOrNotFound",
|
||||||
|
message: "Authorization failed or requested resource not found."}
|
||||||
|
ft400 := fakeServiceError{status: 400, code: "InvalidParameter",
|
||||||
|
message: "Not allowed to call finetune base model ocid1.generativeaimodel.oc1..x, use Endpoint: false"}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"实体不存在 404 命中(含包装链)", fmt.Errorf("genai chat: %w", entity404), true},
|
||||||
|
{"鉴权类 404 不命中(仍属租户级)", auth404, false},
|
||||||
|
{"微调基座 400 命中", ft400, true},
|
||||||
|
{"其他 404 消息不命中", fakeServiceError{status: 404, code: "NotFound", message: "route not found"}, false},
|
||||||
|
{"非服务端错误不命中", errors.New("entity with key x not found"), false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := IsModelUnavailable(tt.err); got != tt.want {
|
||||||
|
t.Errorf("IsModelUnavailable() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+67
-133
@@ -2,13 +2,11 @@ package oci
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/oracle/oci-go-sdk/v65/common"
|
|
||||||
"github.com/oracle/oci-go-sdk/v65/generativeai"
|
"github.com/oracle/oci-go-sdk/v65/generativeai"
|
||||||
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
|
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
|
||||||
|
|
||||||
@@ -17,11 +15,10 @@ import (
|
|||||||
|
|
||||||
// GenAiModel 是区域可用基础模型的摘要(管理面 ListModels)。
|
// GenAiModel 是区域可用基础模型的摘要(管理面 ListModels)。
|
||||||
type GenAiModel struct {
|
type GenAiModel struct {
|
||||||
Ocid string `json:"ocid"`
|
Ocid string `json:"ocid"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Vendor string `json:"vendor"`
|
Vendor string `json:"vendor"`
|
||||||
ChatOnly bool `json:"-"`
|
Caps []string `json:"capabilities"`
|
||||||
Caps []string `json:"capabilities"`
|
|
||||||
// Capability 是网关侧归一能力:CHAT / EMBEDDING(兼具时算 CHAT)
|
// Capability 是网关侧归一能力:CHAT / EMBEDDING(兼具时算 CHAT)
|
||||||
Capability string `json:"capability"`
|
Capability string `json:"capability"`
|
||||||
// Deprecated 是 OCI 宣布的弃用时间(TimeDeprecated),nil 表示未宣布;
|
// Deprecated 是 OCI 宣布的弃用时间(TimeDeprecated),nil 表示未宣布;
|
||||||
@@ -31,12 +28,6 @@ type GenAiModel struct {
|
|||||||
Retired *time.Time `json:"retired"`
|
Retired *time.Time `json:"retired"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenAiStream 逐事件读取流式聊天;Next 在流结束时返回 io.EOF。
|
|
||||||
type GenAiStream interface {
|
|
||||||
Next() (aiwire.ChatChunk, error)
|
|
||||||
Close() error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *RealClient) genAiClient(cred Credentials, region string) (generativeai.GenerativeAiClient, error) {
|
func (c *RealClient) genAiClient(cred Credentials, region string) (generativeai.GenerativeAiClient, error) {
|
||||||
gc, err := generativeai.NewGenerativeAiClientWithConfigurationProvider(provider(cred))
|
gc, err := generativeai.NewGenerativeAiClientWithConfigurationProvider(provider(cred))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -71,18 +62,39 @@ func (c *RealClient) ListGenAiModels(ctx context.Context, cred Credentials, regi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list genai models: %w", err)
|
return nil, fmt.Errorf("list genai models: %w", err)
|
||||||
}
|
}
|
||||||
seen := map[string]bool{}
|
return dedupGenAiModels(resp.Items, time.Now()), nil
|
||||||
now := time.Now()
|
}
|
||||||
|
|
||||||
|
// dedupGenAiModels 压平并按名称去重;同名多条目时优先保留不含 FINE_TUNE 能力的条目
|
||||||
|
// (微调基座条目在部分区域不支持按需调用,缓存其 OCID 会导致调用 400)。
|
||||||
|
func dedupGenAiModels(items []generativeai.ModelSummary, now time.Time) []GenAiModel {
|
||||||
|
seen := map[string]int{}
|
||||||
var out []GenAiModel
|
var out []GenAiModel
|
||||||
for _, m := range resp.Items {
|
for _, m := range items {
|
||||||
gm, ok := toGenAiModel(m, now)
|
gm, ok := toGenAiModel(m, now)
|
||||||
if !ok || seen[gm.Name] {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[gm.Name] = true
|
if i, dup := seen[gm.Name]; dup {
|
||||||
|
if hasFineTune(out[i].Caps) && !hasFineTune(gm.Caps) {
|
||||||
|
out[i] = gm
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[gm.Name] = len(out)
|
||||||
out = append(out, gm)
|
out = append(out, gm)
|
||||||
}
|
}
|
||||||
return out, nil
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasFineTune 判断能力列表是否含 FINE_TUNE(微调基座条目)。
|
||||||
|
func hasFineTune(caps []string) bool {
|
||||||
|
for _, c := range caps {
|
||||||
|
if c == string(generativeai.ModelCapabilityFineTune) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// toGenAiModel 压平模型摘要;无归一能力、无名称或按需推理已退役
|
// toGenAiModel 压平模型摘要;无归一能力、无名称或按需推理已退役
|
||||||
@@ -119,6 +131,11 @@ func modelCapability(m generativeai.ModelSummary) string {
|
|||||||
return "CHAT"
|
return "CHAT"
|
||||||
case generativeai.ModelCapabilityTextEmbeddings:
|
case generativeai.ModelCapabilityTextEmbeddings:
|
||||||
capability = "EMBEDDING"
|
capability = "EMBEDDING"
|
||||||
|
case generativeai.ModelCapabilityTextRerank:
|
||||||
|
capability = "RERANK"
|
||||||
|
case generativeai.ModelCapabilityEnum("TEXT_TO_AUDIO"):
|
||||||
|
// SDK v65.120 尚无该枚举常量,按原始字符串匹配(xai.grok-tts)
|
||||||
|
capability = "TTS"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return capability
|
return capability
|
||||||
@@ -132,121 +149,17 @@ func capStrings(caps []generativeai.ModelCapabilityEnum) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenAiChat 实现 Client:非流式聊天;modelOcid 走 on-demand serving,
|
// GenAiProbeChat 实现 Client:经 OpenAI 兼容面(直通同链路)发一次极小请求探测渠道;
|
||||||
// cohere.* 模型走 COHERE 请求格式,其余走 GENERIC。
|
// 配额探测专用的最小聊天,返回 HTTP 状态码。max_output_tokens 取 16:
|
||||||
func (c *RealClient) GenAiChat(ctx context.Context, cred Credentials, region, modelOcid string, ir aiwire.ChatRequest) (*aiwire.ChatResponse, error) {
|
// openai.gpt-oss 系列要求 >=16,其余模型均兼容,成本差异可忽略。
|
||||||
ic, err := c.genAiInferenceClient(cred, region)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req, err := buildChatRequest(ir, false)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
resp, err := ic.Chat(ctx, chatRequest(cred, modelOcid, req))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("genai chat: %w", err)
|
|
||||||
}
|
|
||||||
return chatResponseToIR(resp.ChatResult.ChatResponse, ir.Model)
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildChatRequest 按模型 vendor 组装底层请求体。
|
|
||||||
func buildChatRequest(ir aiwire.ChatRequest, stream bool) (generativeaiinference.BaseChatRequest, error) {
|
|
||||||
if isCohereModel(ir.Model) {
|
|
||||||
return irToCohereSDK(ir, stream)
|
|
||||||
}
|
|
||||||
return irToSDK(ir, stream), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// chatResponseToIR 按响应实际形态(GENERIC / COHERE)转回 IR。
|
|
||||||
func chatResponseToIR(resp generativeaiinference.BaseChatResponse, model string) (*aiwire.ChatResponse, error) {
|
|
||||||
switch r := resp.(type) {
|
|
||||||
case generativeaiinference.GenericChatResponse:
|
|
||||||
return sdkToIR(r, model), nil
|
|
||||||
case generativeaiinference.CohereChatResponse:
|
|
||||||
return cohereSDKToIR(r, model), nil
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("genai chat: unexpected response format %T", resp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func chatRequest(cred Credentials, modelOcid string, req generativeaiinference.BaseChatRequest) generativeaiinference.ChatRequest {
|
|
||||||
return generativeaiinference.ChatRequest{
|
|
||||||
ChatDetails: generativeaiinference.ChatDetails{
|
|
||||||
CompartmentId: &cred.TenancyOCID,
|
|
||||||
ServingMode: generativeaiinference.OnDemandServingMode{ModelId: &modelOcid},
|
|
||||||
ChatRequest: req,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenAiChatStream 实现 Client:流式聊天,返回逐事件读取器。
|
|
||||||
// SDK 对 text/event-stream 跳过 unmarshal,原始流经 RawResponse 交由 SSEReader 消费。
|
|
||||||
func (c *RealClient) GenAiChatStream(ctx context.Context, cred Credentials, region, modelOcid string, ir aiwire.ChatRequest) (GenAiStream, error) {
|
|
||||||
ic, err := c.genAiInferenceClient(cred, region)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req, err := buildChatRequest(ir, true)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
resp, err := ic.Chat(ctx, chatRequest(cred, modelOcid, req))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("genai chat stream: %w", err)
|
|
||||||
}
|
|
||||||
reader, err := common.NewSSEReader(resp.RawResponse)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("genai chat stream: sse reader: %w", err)
|
|
||||||
}
|
|
||||||
return &genAiSSEStream{reader: reader, body: resp.RawResponse.Body, model: ir.Model}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// genAiSSEStream 把 OCI SSE 事件流适配为 IR chunk 流。
|
|
||||||
type genAiSSEStream struct {
|
|
||||||
reader *common.SseReader
|
|
||||||
body io.ReadCloser
|
|
||||||
model string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Next 读取下一条有效事件;空事件与 [DONE] 哨兵跳过,流尽返回 io.EOF。
|
|
||||||
func (s *genAiSSEStream) Next() (aiwire.ChatChunk, error) {
|
|
||||||
for {
|
|
||||||
data, err := s.reader.ReadNextEvent()
|
|
||||||
if err != nil {
|
|
||||||
return aiwire.ChatChunk{}, err
|
|
||||||
}
|
|
||||||
text := strings.TrimSpace(string(data))
|
|
||||||
if text == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if text == "[DONE]" {
|
|
||||||
return aiwire.ChatChunk{}, io.EOF
|
|
||||||
}
|
|
||||||
if chunk, ok := parseGenAiEvent([]byte(text), s.model); ok {
|
|
||||||
return chunk, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *genAiSSEStream) Close() error {
|
|
||||||
if s.body != nil {
|
|
||||||
return s.body.Close()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenAiProbeChat 实现 Client:配额探测专用的最小聊天(maxTokens=1),返回 HTTP 状态码;
|
|
||||||
// modelName 决定请求格式(cohere.* 走 COHERE)。
|
|
||||||
func (c *RealClient) GenAiProbeChat(ctx context.Context, cred Credentials, region, modelOcid, modelName string) (int, error) {
|
func (c *RealClient) GenAiProbeChat(ctx context.Context, cred Credentials, region, modelOcid, modelName string) (int, error) {
|
||||||
one := 1
|
body, err := json.Marshal(map[string]any{"model": modelName, "input": "hi",
|
||||||
ir := aiwire.ChatRequest{
|
"max_output_tokens": 16, "store": false})
|
||||||
Model: modelName,
|
if err != nil {
|
||||||
Messages: []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent("hi")}},
|
return 0, err
|
||||||
MaxTokens: &one,
|
|
||||||
}
|
}
|
||||||
_, err := c.GenAiChat(ctx, cred, region, modelOcid, ir)
|
// 探测追求快速失败,沿用 SDK 默认量级的 60s 预算即可
|
||||||
if err == nil {
|
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 {
|
||||||
@@ -255,6 +168,27 @@ func (c *RealClient) GenAiProbeChat(ctx context.Context, cred Credentials, regio
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sdkUsageToIR 把 SDK 用量转为内部记账结构(缓存命中挂 details,仅命中时出现)。
|
||||||
|
func sdkUsageToIR(u *generativeaiinference.Usage) *aiwire.Usage {
|
||||||
|
if u == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := &aiwire.Usage{}
|
||||||
|
if u.PromptTokens != nil {
|
||||||
|
out.PromptTokens = *u.PromptTokens
|
||||||
|
}
|
||||||
|
if u.CompletionTokens != nil {
|
||||||
|
out.CompletionTokens = *u.CompletionTokens
|
||||||
|
}
|
||||||
|
if u.TotalTokens != nil {
|
||||||
|
out.TotalTokens = *u.TotalTokens
|
||||||
|
}
|
||||||
|
if u.PromptTokensDetails != nil && u.PromptTokensDetails.CachedTokens != nil && *u.PromptTokensDetails.CachedTokens > 0 {
|
||||||
|
out.PromptTokensDetails = &aiwire.PromptTokensDetails{CachedTokens: *u.PromptTokensDetails.CachedTokens}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// GenAiEmbed 实现 Client:文本向量化(on-demand serving);dimensions 透传 OutputDimensions。
|
// GenAiEmbed 实现 Client:文本向量化(on-demand serving);dimensions 透传 OutputDimensions。
|
||||||
func (c *RealClient) GenAiEmbed(ctx context.Context, cred Credentials, region, modelOcid string, inputs []string, dimensions *int) ([][]float32, *aiwire.Usage, error) {
|
func (c *RealClient) GenAiEmbed(ctx context.Context, cred Credentials, region, modelOcid string, inputs []string, dimensions *int) ([][]float32, *aiwire.Usage, error) {
|
||||||
ic, err := c.genAiInferenceClient(cred, region)
|
ic, err := c.genAiInferenceClient(cred, region)
|
||||||
|
|||||||
@@ -1,266 +0,0 @@
|
|||||||
package oci
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
|
|
||||||
|
|
||||||
"oci-portal/internal/aiwire"
|
|
||||||
)
|
|
||||||
|
|
||||||
// isCohereModel 依据模型名前缀判定 COHERE 系(走专属请求格式)。
|
|
||||||
func isCohereModel(name string) bool {
|
|
||||||
return strings.HasPrefix(strings.ToLower(name), "cohere.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// irToCohereSDK 把 IR 转为 COHERE 聊天请求;工具与结构化输出做有损降级
|
|
||||||
// (参数仅取 JSON Schema 顶层 properties,tool_choice / json_schema 的 name、strict 无对应)。
|
|
||||||
func irToCohereSDK(ir aiwire.ChatRequest, stream bool) (generativeaiinference.CohereChatRequest, error) {
|
|
||||||
var req generativeaiinference.CohereChatRequest
|
|
||||||
if err := cohereRejectUnsupported(ir); err != nil {
|
|
||||||
return req, err
|
|
||||||
}
|
|
||||||
p, err := cohereSplitMessages(ir.Messages)
|
|
||||||
if err != nil {
|
|
||||||
return req, err
|
|
||||||
}
|
|
||||||
req = generativeaiinference.CohereChatRequest{
|
|
||||||
Message: &p.message,
|
|
||||||
ChatHistory: p.history,
|
|
||||||
ToolResults: p.toolResults,
|
|
||||||
Tools: cohereTools(ir.Tools),
|
|
||||||
ResponseFormat: cohereResponseFormat(ir.ResponseFormat),
|
|
||||||
MaxTokens: ir.MaxTokens,
|
|
||||||
Temperature: ir.Temperature,
|
|
||||||
TopP: ir.TopP,
|
|
||||||
TopK: ir.TopK,
|
|
||||||
FrequencyPenalty: ir.FrequencyPenalty,
|
|
||||||
PresencePenalty: ir.PresencePenalty,
|
|
||||||
Seed: ir.Seed,
|
|
||||||
}
|
|
||||||
if p.preamble != "" {
|
|
||||||
req.PreambleOverride = &p.preamble
|
|
||||||
}
|
|
||||||
if len(ir.Stop) > 0 {
|
|
||||||
req.StopSequences = ir.Stop
|
|
||||||
}
|
|
||||||
if stream {
|
|
||||||
req.IsStream = &stream
|
|
||||||
includeUsage := ir.StreamOptions == nil || ir.StreamOptions.IncludeUsage
|
|
||||||
req.StreamOptions = &generativeaiinference.StreamOptions{IsIncludeUsage: &includeUsage}
|
|
||||||
}
|
|
||||||
return req, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// cohereRejectUnsupported 拒绝 COHERE 无法承接的能力(多模态图片)。
|
|
||||||
func cohereRejectUnsupported(ir aiwire.ChatRequest) error {
|
|
||||||
for _, m := range ir.Messages {
|
|
||||||
for _, p := range m.Content.Parts {
|
|
||||||
if p.Type == "image_url" {
|
|
||||||
return fmt.Errorf("cohere 系模型暂不支持图片输入,请改用 meta/google 等多模态模型")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// cohereParts 是 IR 消息拆装为 COHERE 请求的中间结果。
|
|
||||||
type cohereParts struct {
|
|
||||||
message string
|
|
||||||
history []generativeaiinference.CohereMessage
|
|
||||||
preamble string
|
|
||||||
toolResults []generativeaiinference.CohereToolResult
|
|
||||||
}
|
|
||||||
|
|
||||||
// cohereSplitMessages 拆装消息:system 拼 preamble,末尾 user 抽为 message,
|
|
||||||
// assistant(含 toolCalls)与其余 user 进 history,tool 结果转顶层 toolResults
|
|
||||||
// (工具结果在末尾时 message 留空,COHERE 以 toolResults 续跑)。
|
|
||||||
func cohereSplitMessages(msgs []aiwire.ChatMessage) (cohereParts, error) {
|
|
||||||
lastUser, lastTool := -1, -1
|
|
||||||
for i, m := range msgs {
|
|
||||||
switch m.Role {
|
|
||||||
case "user":
|
|
||||||
lastUser = i
|
|
||||||
case "tool":
|
|
||||||
lastTool = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if lastUser == -1 && lastTool == -1 {
|
|
||||||
return cohereParts{}, fmt.Errorf("cohere 系模型至少需要一条 user 消息")
|
|
||||||
}
|
|
||||||
var p cohereParts
|
|
||||||
var preamble []string
|
|
||||||
calls := map[string]generativeaiinference.CohereToolCall{}
|
|
||||||
for i, m := range msgs {
|
|
||||||
text := m.Content.JoinText()
|
|
||||||
switch m.Role {
|
|
||||||
case "system", "developer":
|
|
||||||
preamble = append(preamble, text)
|
|
||||||
case "assistant":
|
|
||||||
p.history = append(p.history, cohereBotMessage(text, m.ToolCalls, calls))
|
|
||||||
case "tool":
|
|
||||||
p.toolResults = append(p.toolResults, cohereToolResult(m, calls))
|
|
||||||
default: // user
|
|
||||||
if i == lastUser && lastUser > lastTool {
|
|
||||||
p.message = text
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
p.history = append(p.history, generativeaiinference.CohereUserMessage{Message: &text})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p.preamble = strings.Join(preamble, "\n")
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// cohereBotMessage 转 assistant 消息;toolCalls 同步登记进 id→call 映射供 tool 结果回查。
|
|
||||||
func cohereBotMessage(text string, tcs []aiwire.ToolCall, calls map[string]generativeaiinference.CohereToolCall) generativeaiinference.CohereChatBotMessage {
|
|
||||||
msg := generativeaiinference.CohereChatBotMessage{}
|
|
||||||
if text != "" {
|
|
||||||
msg.Message = &text
|
|
||||||
}
|
|
||||||
for _, tc := range tcs {
|
|
||||||
name := tc.Function.Name
|
|
||||||
var params interface{}
|
|
||||||
if json.Unmarshal([]byte(tc.Function.Arguments), ¶ms) != nil || params == nil {
|
|
||||||
params = map[string]any{}
|
|
||||||
}
|
|
||||||
call := generativeaiinference.CohereToolCall{Name: &name, Parameters: ¶ms}
|
|
||||||
calls[tc.ID] = call
|
|
||||||
msg.ToolCalls = append(msg.ToolCalls, call)
|
|
||||||
}
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
|
|
||||||
// cohereToolResult 把 IR tool 消息转顶层工具结果;COHERE 工具调用无 id,
|
|
||||||
// 靠 assistant 历史登记的映射回查,缺失时以 tool_call_id 名义调用兜底。
|
|
||||||
func cohereToolResult(m aiwire.ChatMessage, calls map[string]generativeaiinference.CohereToolCall) generativeaiinference.CohereToolResult {
|
|
||||||
call, ok := calls[m.ToolCallID]
|
|
||||||
if !ok {
|
|
||||||
name := m.ToolCallID
|
|
||||||
var params interface{} = map[string]any{}
|
|
||||||
call = generativeaiinference.CohereToolCall{Name: &name, Parameters: ¶ms}
|
|
||||||
}
|
|
||||||
text := m.Content.JoinText()
|
|
||||||
var output interface{}
|
|
||||||
if json.Unmarshal([]byte(text), &output) != nil || output == nil {
|
|
||||||
output = map[string]any{"output": text}
|
|
||||||
}
|
|
||||||
if arr, isArr := output.([]interface{}); isArr {
|
|
||||||
return generativeaiinference.CohereToolResult{Call: &call, Outputs: arr}
|
|
||||||
}
|
|
||||||
if _, isMap := output.(map[string]interface{}); !isMap {
|
|
||||||
output = map[string]any{"output": output}
|
|
||||||
}
|
|
||||||
return generativeaiinference.CohereToolResult{Call: &call, Outputs: []interface{}{output}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// cohereTools 把 JSON Schema 工具定义降级为 COHERE 扁平参数表(嵌套结构有损:仅取顶层)。
|
|
||||||
func cohereTools(tools []aiwire.Tool) []generativeaiinference.CohereTool {
|
|
||||||
if len(tools) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]generativeaiinference.CohereTool, 0, len(tools))
|
|
||||||
for _, t := range tools {
|
|
||||||
name, desc := t.Function.Name, t.Function.Description
|
|
||||||
if desc == "" {
|
|
||||||
desc = name // Description 为 COHERE 必填
|
|
||||||
}
|
|
||||||
out = append(out, generativeaiinference.CohereTool{
|
|
||||||
Name: &name, Description: &desc,
|
|
||||||
ParameterDefinitions: cohereParams(t.Function.Parameters),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// cohereParams 取 JSON Schema 顶层 properties 转扁平参数定义;嵌套 schema 只保留类型名。
|
|
||||||
func cohereParams(schema json.RawMessage) map[string]generativeaiinference.CohereParameterDefinition {
|
|
||||||
var s struct {
|
|
||||||
Properties map[string]struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
} `json:"properties"`
|
|
||||||
Required []string `json:"required"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(schema, &s) != nil || len(s.Properties) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
req := map[string]bool{}
|
|
||||||
for _, r := range s.Required {
|
|
||||||
req[r] = true
|
|
||||||
}
|
|
||||||
out := make(map[string]generativeaiinference.CohereParameterDefinition, len(s.Properties))
|
|
||||||
for k, v := range s.Properties {
|
|
||||||
typ, desc := v.Type, v.Description
|
|
||||||
if typ == "" {
|
|
||||||
typ = "string"
|
|
||||||
}
|
|
||||||
pd := generativeaiinference.CohereParameterDefinition{Type: &typ}
|
|
||||||
if desc != "" {
|
|
||||||
pd.Description = &desc
|
|
||||||
}
|
|
||||||
if req[k] {
|
|
||||||
t := true
|
|
||||||
pd.IsRequired = &t
|
|
||||||
}
|
|
||||||
out[k] = pd
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// cohereResponseFormat 映射 json_object / json_schema(name、strict 无对应,有损降级)。
|
|
||||||
func cohereResponseFormat(rf *aiwire.ResponseFormat) generativeaiinference.CohereResponseFormat {
|
|
||||||
if rf == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
switch rf.Type {
|
|
||||||
case "json_object", "json_schema":
|
|
||||||
out := generativeaiinference.CohereResponseJsonFormat{}
|
|
||||||
var in struct {
|
|
||||||
Schema json.RawMessage `json:"schema"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(rf.JSONSchema, &in) == nil && len(in.Schema) > 0 {
|
|
||||||
var schema interface{}
|
|
||||||
if json.Unmarshal(in.Schema, &schema) == nil {
|
|
||||||
out.Schema = &schema
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// cohereSDKToIR 把 COHERE 非流式响应转回 IR;工具调用补派生 ID,存在时结束原因归 tool_calls。
|
|
||||||
func cohereSDKToIR(resp generativeaiinference.CohereChatResponse, model string) *aiwire.ChatResponse {
|
|
||||||
msg := aiwire.ChatMessage{Role: "assistant", Content: aiwire.NewTextContent(deref(resp.Text))}
|
|
||||||
for i, tc := range resp.ToolCalls {
|
|
||||||
msg.ToolCalls = append(msg.ToolCalls, cohereCallToIR(deref(tc.Name), tc.Parameters, i))
|
|
||||||
}
|
|
||||||
finish := mapFinishReason(string(resp.FinishReason))
|
|
||||||
if len(msg.ToolCalls) > 0 {
|
|
||||||
finish = "tool_calls"
|
|
||||||
}
|
|
||||||
return &aiwire.ChatResponse{
|
|
||||||
Object: "chat.completion",
|
|
||||||
Model: model,
|
|
||||||
Choices: []aiwire.Choice{{Message: msg, FinishReason: finish}},
|
|
||||||
Usage: sdkUsageToIR(resp.Usage),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// cohereCallToIR 生成 IR 工具调用;COHERE 无调用 id,按名称+序号派生(回传时按此回查)。
|
|
||||||
func cohereCallToIR(name string, params *interface{}, idx int) aiwire.ToolCall {
|
|
||||||
args := "{}"
|
|
||||||
if params != nil && *params != nil {
|
|
||||||
if b, err := json.Marshal(*params); err == nil {
|
|
||||||
args = string(b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return aiwire.ToolCall{
|
|
||||||
ID: fmt.Sprintf("call_%s_%d", name, idx),
|
|
||||||
Type: "function",
|
|
||||||
Function: aiwire.FunctionCall{Name: name, Arguments: args},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
package oci
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
|
|
||||||
|
|
||||||
"oci-portal/internal/aiwire"
|
|
||||||
)
|
|
||||||
|
|
||||||
// irToSDK 把 IR(OpenAI 线格式)转为 OCI GENERIC 聊天请求;字段近一一镜像。
|
|
||||||
func irToSDK(ir aiwire.ChatRequest, stream bool) generativeaiinference.GenericChatRequest {
|
|
||||||
req := generativeaiinference.GenericChatRequest{
|
|
||||||
Messages: irMessages(ir.Messages),
|
|
||||||
MaxTokens: ir.MaxTokens,
|
|
||||||
MaxCompletionTokens: ir.MaxCompletionTokens,
|
|
||||||
Temperature: ir.Temperature,
|
|
||||||
TopP: ir.TopP,
|
|
||||||
TopK: ir.TopK,
|
|
||||||
FrequencyPenalty: ir.FrequencyPenalty,
|
|
||||||
PresencePenalty: ir.PresencePenalty,
|
|
||||||
Seed: ir.Seed,
|
|
||||||
NumGenerations: ir.N,
|
|
||||||
Tools: irTools(ir.Tools),
|
|
||||||
ToolChoice: irToolChoice(ir.ToolChoice),
|
|
||||||
ResponseFormat: irResponseFormat(ir.ResponseFormat),
|
|
||||||
}
|
|
||||||
if len(ir.Stop) > 0 {
|
|
||||||
req.Stop = ir.Stop
|
|
||||||
}
|
|
||||||
if ir.ReasoningEffort != "" {
|
|
||||||
req.ReasoningEffort = generativeaiinference.GenericChatRequestReasoningEffortEnum(strings.ToUpper(ir.ReasoningEffort))
|
|
||||||
}
|
|
||||||
if stream {
|
|
||||||
req.IsStream = &stream
|
|
||||||
includeUsage := ir.StreamOptions == nil || ir.StreamOptions.IncludeUsage
|
|
||||||
req.StreamOptions = &generativeaiinference.StreamOptions{IsIncludeUsage: &includeUsage}
|
|
||||||
}
|
|
||||||
return req
|
|
||||||
}
|
|
||||||
|
|
||||||
// irMessages 按 role 拆装消息;user 消息保留图文块,其余角色只取文本。
|
|
||||||
func irMessages(msgs []aiwire.ChatMessage) []generativeaiinference.Message {
|
|
||||||
out := make([]generativeaiinference.Message, 0, len(msgs))
|
|
||||||
for _, m := range msgs {
|
|
||||||
switch m.Role {
|
|
||||||
case "system", "developer":
|
|
||||||
out = append(out, generativeaiinference.SystemMessage{Content: textContents(m.Content.JoinText())})
|
|
||||||
case "assistant":
|
|
||||||
out = append(out, generativeaiinference.AssistantMessage{
|
|
||||||
Content: textContents(m.Content.JoinText()),
|
|
||||||
ToolCalls: irToolCalls(m.ToolCalls),
|
|
||||||
})
|
|
||||||
case "tool":
|
|
||||||
tm := generativeaiinference.ToolMessage{Content: textContents(m.Content.JoinText())}
|
|
||||||
if m.ToolCallID != "" {
|
|
||||||
id := m.ToolCallID
|
|
||||||
tm.ToolCallId = &id
|
|
||||||
}
|
|
||||||
out = append(out, tm)
|
|
||||||
default: // user
|
|
||||||
out = append(out, generativeaiinference.UserMessage{Content: chatContents(m.Content)})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// chatContents 把 IR 内容转 SDK 内容块;文本直通,image_url 转 IMAGE 块(data URI / 公网地址)。
|
|
||||||
func chatContents(c aiwire.Content) []generativeaiinference.ChatContent {
|
|
||||||
if len(c.Parts) == 0 {
|
|
||||||
return textContents(c.Text)
|
|
||||||
}
|
|
||||||
var out []generativeaiinference.ChatContent
|
|
||||||
for _, p := range c.Parts {
|
|
||||||
switch {
|
|
||||||
case p.Type == "image_url" && p.ImageURL != nil:
|
|
||||||
img := generativeaiinference.ImageUrl{Url: &p.ImageURL.URL}
|
|
||||||
if d, ok := generativeaiinference.GetMappingImageUrlDetailEnum(p.ImageURL.Detail); ok {
|
|
||||||
img.Detail = d
|
|
||||||
}
|
|
||||||
out = append(out, generativeaiinference.ImageContent{ImageUrl: &img})
|
|
||||||
case p.Text != "":
|
|
||||||
t := p.Text
|
|
||||||
out = append(out, generativeaiinference.TextContent{Text: &t})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// textContents 把纯文本包装为单元素 TextContent 数组;空文本返回 nil。
|
|
||||||
func textContents(text string) []generativeaiinference.ChatContent {
|
|
||||||
if text == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []generativeaiinference.ChatContent{generativeaiinference.TextContent{Text: &text}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func irToolCalls(calls []aiwire.ToolCall) []generativeaiinference.ToolCall {
|
|
||||||
if len(calls) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]generativeaiinference.ToolCall, 0, len(calls))
|
|
||||||
for _, c := range calls {
|
|
||||||
id, name, args := c.ID, c.Function.Name, c.Function.Arguments
|
|
||||||
out = append(out, generativeaiinference.FunctionCall{Id: &id, Name: &name, Arguments: &args})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func irTools(tools []aiwire.Tool) []generativeaiinference.ToolDefinition {
|
|
||||||
if len(tools) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]generativeaiinference.ToolDefinition, 0, len(tools))
|
|
||||||
for _, t := range tools {
|
|
||||||
name, desc := t.Function.Name, t.Function.Description
|
|
||||||
fd := generativeaiinference.FunctionDefinition{Name: &name}
|
|
||||||
if desc != "" {
|
|
||||||
fd.Description = &desc
|
|
||||||
}
|
|
||||||
if len(t.Function.Parameters) > 0 {
|
|
||||||
var params interface{}
|
|
||||||
if json.Unmarshal(t.Function.Parameters, ¶ms) == nil {
|
|
||||||
fd.Parameters = ¶ms
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out = append(out, fd)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// irToolChoice 解析 "auto"/"none"/"required" 或 {type:function,function:{name}}。
|
|
||||||
func irToolChoice(raw json.RawMessage) generativeaiinference.ToolChoice {
|
|
||||||
if len(raw) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var s string
|
|
||||||
if json.Unmarshal(raw, &s) == nil {
|
|
||||||
switch s {
|
|
||||||
case "none":
|
|
||||||
return generativeaiinference.ToolChoiceNone{}
|
|
||||||
case "required":
|
|
||||||
return generativeaiinference.ToolChoiceRequired{}
|
|
||||||
case "auto":
|
|
||||||
return generativeaiinference.ToolChoiceAuto{}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var obj struct {
|
|
||||||
Function struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
} `json:"function"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(raw, &obj) == nil && obj.Function.Name != "" {
|
|
||||||
return generativeaiinference.ToolChoiceFunction{Name: &obj.Function.Name}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func irResponseFormat(rf *aiwire.ResponseFormat) generativeaiinference.ResponseFormat {
|
|
||||||
if rf == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
switch rf.Type {
|
|
||||||
case "json_object":
|
|
||||||
return generativeaiinference.JsonObjectResponseFormat{}
|
|
||||||
case "json_schema":
|
|
||||||
return irJSONSchemaFormat(rf.JSONSchema)
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// irJSONSchemaFormat 映射 OpenAI json_schema{name,description,schema,strict}。
|
|
||||||
func irJSONSchemaFormat(raw json.RawMessage) generativeaiinference.ResponseFormat {
|
|
||||||
var in struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Schema json.RawMessage `json:"schema"`
|
|
||||||
Strict *bool `json:"strict"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(raw, &in) != nil || in.Name == "" {
|
|
||||||
return generativeaiinference.JsonObjectResponseFormat{}
|
|
||||||
}
|
|
||||||
js := generativeaiinference.ResponseJsonSchema{Name: &in.Name, IsStrict: in.Strict}
|
|
||||||
if in.Description != "" {
|
|
||||||
js.Description = &in.Description
|
|
||||||
}
|
|
||||||
if len(in.Schema) > 0 {
|
|
||||||
var schema interface{}
|
|
||||||
if json.Unmarshal(in.Schema, &schema) == nil {
|
|
||||||
js.Schema = &schema
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return generativeaiinference.JsonSchemaResponseFormat{JsonSchema: &js}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sdkToIR 把非流式 GenericChatResponse 转回 IR 响应;id/created 由调用方补。
|
|
||||||
func sdkToIR(resp generativeaiinference.GenericChatResponse, model string) *aiwire.ChatResponse {
|
|
||||||
out := &aiwire.ChatResponse{Object: "chat.completion", Model: model}
|
|
||||||
if resp.TimeCreated != nil {
|
|
||||||
out.Created = resp.TimeCreated.Unix()
|
|
||||||
}
|
|
||||||
for i, ch := range resp.Choices {
|
|
||||||
idx := i
|
|
||||||
if ch.Index != nil {
|
|
||||||
idx = *ch.Index
|
|
||||||
}
|
|
||||||
out.Choices = append(out.Choices, aiwire.Choice{
|
|
||||||
Index: idx,
|
|
||||||
Message: sdkMessageToIR(ch.Message),
|
|
||||||
FinishReason: mapFinishReason(deref(ch.FinishReason)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
out.Usage = sdkUsageToIR(resp.Usage)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func sdkUsageToIR(u *generativeaiinference.Usage) *aiwire.Usage {
|
|
||||||
if u == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := &aiwire.Usage{}
|
|
||||||
if u.PromptTokens != nil {
|
|
||||||
out.PromptTokens = *u.PromptTokens
|
|
||||||
}
|
|
||||||
if u.CompletionTokens != nil {
|
|
||||||
out.CompletionTokens = *u.CompletionTokens
|
|
||||||
}
|
|
||||||
if u.TotalTokens != nil {
|
|
||||||
out.TotalTokens = *u.TotalTokens
|
|
||||||
}
|
|
||||||
if d := u.PromptTokensDetails; d != nil && d.CachedTokens != nil && *d.CachedTokens > 0 {
|
|
||||||
out.PromptTokensDetails = &aiwire.PromptTokensDetails{CachedTokens: *d.CachedTokens}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// sdkMessageToIR 提取助手消息文本与工具调用。
|
|
||||||
func sdkMessageToIR(m generativeaiinference.Message) aiwire.ChatMessage {
|
|
||||||
out := aiwire.ChatMessage{Role: "assistant"}
|
|
||||||
am, ok := m.(generativeaiinference.AssistantMessage)
|
|
||||||
if !ok {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
var sb strings.Builder
|
|
||||||
for _, c := range am.Content {
|
|
||||||
if tc, ok := c.(generativeaiinference.TextContent); ok && tc.Text != nil {
|
|
||||||
sb.WriteString(*tc.Text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.Content = aiwire.NewTextContent(sb.String())
|
|
||||||
for _, call := range am.ToolCalls {
|
|
||||||
if fc, ok := call.(generativeaiinference.FunctionCall); ok {
|
|
||||||
out.ToolCalls = append(out.ToolCalls, aiwire.ToolCall{
|
|
||||||
ID: deref(fc.Id),
|
|
||||||
Type: "function",
|
|
||||||
Function: aiwire.FunctionCall{
|
|
||||||
Name: deref(fc.Name),
|
|
||||||
Arguments: deref(fc.Arguments),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// mapFinishReason 归一化结束原因;OCI 与 OpenAI 取值同名,做小写与别名兜底。
|
|
||||||
func mapFinishReason(reason string) string {
|
|
||||||
switch strings.ToLower(reason) {
|
|
||||||
case "", "null":
|
|
||||||
return ""
|
|
||||||
case "stop", "completed", "end_turn":
|
|
||||||
return "stop"
|
|
||||||
case "length", "max_tokens":
|
|
||||||
return "length"
|
|
||||||
case "tool_calls", "tool_call", "tool_use":
|
|
||||||
return "tool_calls"
|
|
||||||
case "complete":
|
|
||||||
return "stop"
|
|
||||||
case "error_toxic":
|
|
||||||
return "content_filter"
|
|
||||||
default:
|
|
||||||
return strings.ToLower(reason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// genAiStreamEvent 是 GENERIC 流式事件的宽容解析结构(字段按增量 choice 形状)。
|
|
||||||
type genAiStreamEvent struct {
|
|
||||||
Index *int `json:"index"`
|
|
||||||
Message struct {
|
|
||||||
Role string `json:"role"`
|
|
||||||
Content []struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
} `json:"content"`
|
|
||||||
ToolCalls []struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Arguments string `json:"arguments"`
|
|
||||||
} `json:"toolCalls"`
|
|
||||||
} `json:"message"`
|
|
||||||
FinishReason *string `json:"finishReason"`
|
|
||||||
Usage *generativeaiinference.Usage `json:"usage"`
|
|
||||||
Choices []json.RawMessage `json:"choices"` // 兼容整包 choices 形态
|
|
||||||
Text string `json:"text"` // COHERE 流事件的顶层增量文本
|
|
||||||
// CohereCalls 是 COHERE 流事件顶层的工具调用(与 GENERIC 的 message.toolCalls 不同层级)
|
|
||||||
CohereCalls []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Parameters interface{} `json:"parameters"`
|
|
||||||
} `json:"toolCalls"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseGenAiEvent 把一条 SSE 事件 JSON 解析为 IR chunk;无有效负载时 ok=false。
|
|
||||||
func parseGenAiEvent(data []byte, model string) (aiwire.ChatChunk, bool) {
|
|
||||||
var ev genAiStreamEvent
|
|
||||||
if err := json.Unmarshal(data, &ev); err != nil {
|
|
||||||
return aiwire.ChatChunk{}, false
|
|
||||||
}
|
|
||||||
if len(ev.Choices) > 0 { // choices 包裹形态:取首个展开重解析
|
|
||||||
var inner genAiStreamEvent
|
|
||||||
if json.Unmarshal(ev.Choices[0], &inner) == nil {
|
|
||||||
inner.Usage = ev.Usage
|
|
||||||
ev = inner
|
|
||||||
}
|
|
||||||
}
|
|
||||||
chunk := aiwire.ChatChunk{Object: "chat.completion.chunk", Model: model}
|
|
||||||
choice := aiwire.ChunkChoice{}
|
|
||||||
if ev.Index != nil {
|
|
||||||
choice.Index = *ev.Index
|
|
||||||
}
|
|
||||||
choice.Delta.Role = strings.ToLower(ev.Message.Role)
|
|
||||||
var sb strings.Builder
|
|
||||||
for _, c := range ev.Message.Content {
|
|
||||||
sb.WriteString(c.Text)
|
|
||||||
}
|
|
||||||
if sb.Len() == 0 && ev.Text != "" { // COHERE 事件形态
|
|
||||||
sb.WriteString(ev.Text)
|
|
||||||
}
|
|
||||||
choice.Delta.Content = sb.String()
|
|
||||||
for i, tc := range ev.Message.ToolCalls {
|
|
||||||
d := aiwire.ToolCallDelta{Index: i, ID: tc.ID}
|
|
||||||
if tc.ID != "" {
|
|
||||||
d.Type = "function"
|
|
||||||
}
|
|
||||||
d.Function.Name = tc.Name
|
|
||||||
d.Function.Arguments = tc.Arguments
|
|
||||||
choice.Delta.ToolCalls = append(choice.Delta.ToolCalls, d)
|
|
||||||
}
|
|
||||||
for i, tc := range ev.CohereCalls { // COHERE 顶层工具调用:整包出现,补派生 ID
|
|
||||||
call := cohereCallToIR(tc.Name, &tc.Parameters, i)
|
|
||||||
d := aiwire.ToolCallDelta{Index: len(choice.Delta.ToolCalls) + i, ID: call.ID, Type: "function"}
|
|
||||||
d.Function.Name = call.Function.Name
|
|
||||||
d.Function.Arguments = call.Function.Arguments
|
|
||||||
choice.Delta.ToolCalls = append(choice.Delta.ToolCalls, d)
|
|
||||||
}
|
|
||||||
if r := mapFinishReason(deref(ev.FinishReason)); r != "" {
|
|
||||||
choice.FinishReason = &r
|
|
||||||
}
|
|
||||||
hasPayload := choice.Delta.Content != "" || len(choice.Delta.ToolCalls) > 0 || choice.FinishReason != nil
|
|
||||||
if hasPayload {
|
|
||||||
chunk.Choices = []aiwire.ChunkChoice{choice}
|
|
||||||
}
|
|
||||||
chunk.Usage = sdkUsageToIR(ev.Usage)
|
|
||||||
return chunk, hasPayload || chunk.Usage != nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
// 真实 OCI GenAI effort 探针(重建版)。
|
||||||
|
//
|
||||||
|
// 前身 genai_cache_integration_test.go 承载缓存/亲和/服务端工具等历轮调研模式,
|
||||||
|
// 调研结论均已归档(.trellis/tasks/archive/2026-07/*/research/),该文件已删除;
|
||||||
|
// 本文件重建共享基础设施,保留 effort 支持矩阵探测,并新增 responses 端点探测
|
||||||
|
// (multi-agent 模型仅允许走 Responses 面)。
|
||||||
|
//
|
||||||
|
// 运行(默认跳过,不触网):
|
||||||
|
//
|
||||||
|
// OCI_GENAI_CACHE_PROBE=effort-matrix OCI_CACHE_DB=<db 绝对路径> DATA_KEY=<主密钥> \
|
||||||
|
// OCI_CACHE_RUN_ID=<唯一标识> go test ./internal/oci/ -run TestRealGenAiEffortMatrix -v
|
||||||
|
//
|
||||||
|
// 可选:OCI_CACHE_CONFIG_ID(默认 6)、OCI_CACHE_REGION(默认 us-chicago-1)、
|
||||||
|
// OCI_EFFORT_CASES 自定义用例("model=effort" 逗号分隔;effort 后缀 "@responses"
|
||||||
|
// 表示走 /actions/v1/responses 端点,如 "xai.grok-4.20-multi-agent=low@responses")。
|
||||||
|
//
|
||||||
|
// 纪律:数据库只读打开;OCI_GO_SDK_DEBUG 必须为空;日志不落原始请求体与凭据,
|
||||||
|
// 错误消息经 OCID 遮掩后截断。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
|
||||||
|
projectcrypto "oci-portal/internal/crypto"
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
effortProbeRegion = "us-chicago-1"
|
||||||
|
effortProbeLimit = int64(4 << 20)
|
||||||
|
)
|
||||||
|
|
||||||
|
var effortRunIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
|
||||||
|
|
||||||
|
type effortProbeCase struct {
|
||||||
|
model string
|
||||||
|
effort string
|
||||||
|
responses bool // true 走 /actions/v1/responses,否则 typed /actions/chat
|
||||||
|
stream bool // 仅 responses 面:请求 SSE 流,观测事件类型序列
|
||||||
|
}
|
||||||
|
|
||||||
|
type effortProbeResources struct {
|
||||||
|
cred Credentials
|
||||||
|
region string
|
||||||
|
modelOCID map[string]string
|
||||||
|
inference generativeaiinference.GenerativeAiInferenceClient
|
||||||
|
}
|
||||||
|
|
||||||
|
type effortProbeResult struct {
|
||||||
|
status int
|
||||||
|
requestRef string
|
||||||
|
duration time.Duration
|
||||||
|
errorCode string
|
||||||
|
errorMessage string
|
||||||
|
completionTokens int
|
||||||
|
reasoningTokens int
|
||||||
|
answerLen int
|
||||||
|
outputTypes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// effortMatrixCases 默认批为 grok-4.3 五档基线;全模型矩阵结论见任务归档,
|
||||||
|
// 复测用 OCI_EFFORT_CASES 自定义。
|
||||||
|
func effortMatrixCases() []effortProbeCase {
|
||||||
|
if raw := os.Getenv("OCI_EFFORT_CASES"); raw != "" {
|
||||||
|
return parseEffortCases(raw)
|
||||||
|
}
|
||||||
|
return []effortProbeCase{
|
||||||
|
{model: "xai.grok-4.3", effort: "none", responses: true},
|
||||||
|
{model: "xai.grok-4.3", effort: "low", responses: true},
|
||||||
|
{model: "xai.grok-4.3", effort: "high", responses: true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseEffortCases 解析 "model=effort[@responses|@responses-stream]" 逗号分隔用例。
|
||||||
|
func parseEffortCases(raw string) []effortProbeCase {
|
||||||
|
var out []effortProbeCase
|
||||||
|
for _, item := range strings.Split(raw, ",") {
|
||||||
|
parts := strings.SplitN(strings.TrimSpace(item), "=", 2)
|
||||||
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
effort, streaming := strings.CutSuffix(parts[1], "@responses-stream")
|
||||||
|
if streaming {
|
||||||
|
out = append(out, effortProbeCase{model: parts[0], effort: effort, responses: true, stream: true})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
effort, _ = strings.CutSuffix(effort, "@responses") // 后缀兼容保留;所有用例均走 responses 面
|
||||||
|
out = append(out, effortProbeCase{model: parts[0], effort: effort, responses: true})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRealGenAiEffortMatrix(t *testing.T) {
|
||||||
|
requireEffortProbeMode(t, "effort-matrix")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
resources := newEffortProbeResources(t, ctx)
|
||||||
|
for _, tc := range effortMatrixCases() {
|
||||||
|
runEffortCase(t, ctx, resources, tc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireEffortProbeMode(t *testing.T, wanted string) {
|
||||||
|
t.Helper()
|
||||||
|
if os.Getenv("OCI_GENAI_CACHE_PROBE") != wanted {
|
||||||
|
t.Skipf("set OCI_GENAI_CACHE_PROBE=%s to run", wanted)
|
||||||
|
}
|
||||||
|
if os.Getenv("OCI_GO_SDK_DEBUG") != "" {
|
||||||
|
t.Fatal("OCI_GO_SDK_DEBUG must be unset to protect signed requests")
|
||||||
|
}
|
||||||
|
if !effortRunIDPattern.MatchString(os.Getenv("OCI_CACHE_RUN_ID")) {
|
||||||
|
t.Fatal("set a unique OCI_CACHE_RUN_ID (1-64 safe characters)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runEffortCase(t *testing.T, ctx context.Context, resources effortProbeResources, tc effortProbeCase) {
|
||||||
|
t.Helper()
|
||||||
|
ocid, ok := resources.modelOCID[tc.model]
|
||||||
|
if !ok {
|
||||||
|
t.Logf("model=%s effort=%s resolve-failed: model not in on-demand catalog", tc.model, tc.effort)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = ocid
|
||||||
|
body, path, err := effortProbeBody(tc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build effort body: %v", err)
|
||||||
|
}
|
||||||
|
result := executeEffortProbe(ctx, resources, path, body)
|
||||||
|
logEffortResult(t, tc, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// effortProbeBody 构造 OpenAI Responses 直通请求体(typed chat 面已随网关剔除,
|
||||||
|
// 探针仅保留 responses 端点;历史 typed 结论见任务归档)。
|
||||||
|
func effortProbeBody(tc effortProbeCase) ([]byte, string, error) {
|
||||||
|
question := "How many positive divisors does 360 have? Answer with the number only."
|
||||||
|
body := map[string]interface{}{"model": tc.model, "input": question,
|
||||||
|
"max_output_tokens": 2048, "store": false}
|
||||||
|
if tc.effort != "" {
|
||||||
|
body["reasoning"] = map[string]string{"effort": tc.effort}
|
||||||
|
}
|
||||||
|
if tc.stream {
|
||||||
|
body["stream"] = true
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(body)
|
||||||
|
return payload, "/actions/v1/responses", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeEffortProbe 以 IAM 签名裸 POST 指定路径;responses 面按生产实现补 compartment 头。
|
||||||
|
func executeEffortProbe(ctx context.Context, resources effortProbeResources, path string, body []byte) effortProbeResult {
|
||||||
|
client := resources.inference.BaseClient
|
||||||
|
client.Configuration.CircuitBreaker = nil
|
||||||
|
common.UpdateEndpointTemplateForOptions(&client)
|
||||||
|
common.SetMissingTemplateParams(&client)
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, path, strings.NewReader(string(body)))
|
||||||
|
if err != nil {
|
||||||
|
return effortProbeResult{errorCode: "BuildRequest", errorMessage: err.Error()}
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
if path != "/actions/chat" {
|
||||||
|
request.Header.Set("CompartmentId", resources.cred.TenancyOCID)
|
||||||
|
request.Header.Set("opc-compartment-id", resources.cred.TenancyOCID)
|
||||||
|
}
|
||||||
|
return effortCallProbe(ctx, client, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func effortCallProbe(ctx context.Context, client common.BaseClient, request *http.Request) effortProbeResult {
|
||||||
|
started := time.Now()
|
||||||
|
response, err := client.Call(ctx, request)
|
||||||
|
result := effortProbeResult{duration: time.Since(started)}
|
||||||
|
if response != nil {
|
||||||
|
result.status = response.StatusCode
|
||||||
|
result.requestRef = shortEffortRef(response.Header.Get("opc-request-id"))
|
||||||
|
defer response.Body.Close()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
result.errorCode, result.errorMessage = effortProbeError(err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
payload, readErr := io.ReadAll(io.LimitReader(response.Body, effortProbeLimit))
|
||||||
|
if readErr != nil {
|
||||||
|
result.errorCode, result.errorMessage = "ReadResponse", readErr.Error()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
parseEffortPayload(&result, payload)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func effortProbeError(err error) (string, string) {
|
||||||
|
if serviceErr, ok := common.IsServiceError(err); ok {
|
||||||
|
return fmt.Sprintf("%d", serviceErr.GetHTTPStatusCode()), sanitizeEffortText(serviceErr.GetMessage())
|
||||||
|
}
|
||||||
|
return "CallError", sanitizeEffortText(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseEffortPayload 兼容三种 usage 形态:typed 驼峰(chatResponse.usage.completionTokens)、
|
||||||
|
// chat 兼容面下划线(usage.completion_tokens)、responses 面(usage.output_tokens)。
|
||||||
|
func parseEffortPayload(result *effortProbeResult, payload []byte) {
|
||||||
|
if text := strings.TrimSpace(string(payload)); strings.HasPrefix(text, "event:") || strings.HasPrefix(text, "data:") {
|
||||||
|
parseEffortSSE(result, text)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var root map[string]interface{}
|
||||||
|
if json.Unmarshal(payload, &root) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result.answerLen = len(parseEffortAnswer(root))
|
||||||
|
result.outputTypes = parseEffortOutputTypes(root)
|
||||||
|
if typed, ok := root["chatResponse"].(map[string]interface{}); ok {
|
||||||
|
root = typed
|
||||||
|
}
|
||||||
|
usage, _ := root["usage"].(map[string]interface{})
|
||||||
|
for _, key := range []string{"completionTokens", "completion_tokens", "output_tokens"} {
|
||||||
|
if v, ok := effortInt(usage, key); ok {
|
||||||
|
result.completionTokens = v
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, detailsKey := range []string{"completionTokensDetails", "completion_tokens_details", "output_tokens_details"} {
|
||||||
|
details, _ := usage[detailsKey].(map[string]interface{})
|
||||||
|
for _, key := range []string{"reasoningTokens", "reasoning_tokens"} {
|
||||||
|
if v, ok := effortInt(details, key); ok {
|
||||||
|
result.reasoningTokens = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseEffortAnswer 提取正文文本:typed 取 chatResponse.choices[].message.content[].text,
|
||||||
|
// responses 取 output[] 里 message 项的 content[].text。仅在内存中量长度,不落日志。
|
||||||
|
func parseEffortAnswer(root map[string]interface{}) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
if typed, ok := root["chatResponse"].(map[string]interface{}); ok {
|
||||||
|
choices, _ := typed["choices"].([]interface{})
|
||||||
|
for _, c := range choices {
|
||||||
|
cm, _ := c.(map[string]interface{})
|
||||||
|
msg, _ := cm["message"].(map[string]interface{})
|
||||||
|
collectEffortText(&sb, msg["content"])
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
output, _ := root["output"].([]interface{})
|
||||||
|
for _, item := range output {
|
||||||
|
im, _ := item.(map[string]interface{})
|
||||||
|
if im["type"] == "message" {
|
||||||
|
collectEffortText(&sb, im["content"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectEffortText(sb *strings.Builder, content interface{}) {
|
||||||
|
parts, _ := content.([]interface{})
|
||||||
|
for _, p := range parts {
|
||||||
|
pm, _ := p.(map[string]interface{})
|
||||||
|
if text, ok := pm["text"].(string); ok {
|
||||||
|
sb.WriteString(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseEffortSSE 汇总 SSE 流:事件类型去重序列进 outputTypes,正文增量长度进 answerLen,
|
||||||
|
// 末尾 completed 事件里的 usage 进 tokens 字段。
|
||||||
|
func parseEffortSSE(result *effortProbeResult, text string) {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, line := range strings.Split(text, "\n") {
|
||||||
|
if name, ok := strings.CutPrefix(line, "event: "); ok {
|
||||||
|
if name = strings.TrimSpace(name); !seen[name] {
|
||||||
|
seen[name] = true
|
||||||
|
result.outputTypes = append(result.outputTypes, name)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, ok := strings.CutPrefix(line, "data: ")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var ev map[string]interface{}
|
||||||
|
if json.Unmarshal([]byte(data), &ev) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if name, ok := ev["type"].(string); ok && !seen[name] { // 纯 data: 行流(无 event: 行)从事件体取类型
|
||||||
|
seen[name] = true
|
||||||
|
result.outputTypes = append(result.outputTypes, name)
|
||||||
|
}
|
||||||
|
if delta, ok := ev["delta"].(string); ok && strings.Contains(fmt.Sprint(ev["type"]), "output_text") {
|
||||||
|
result.answerLen += len(delta)
|
||||||
|
}
|
||||||
|
if resp, ok := ev["response"].(map[string]interface{}); ok {
|
||||||
|
usage, _ := resp["usage"].(map[string]interface{})
|
||||||
|
if v, ok := effortInt(usage, "output_tokens"); ok {
|
||||||
|
result.completionTokens = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseEffortOutputTypes 收集 responses 面 output 项类型序列(multi-agent 行为观测)。
|
||||||
|
func parseEffortOutputTypes(root map[string]interface{}) []string {
|
||||||
|
output, _ := root["output"].([]interface{})
|
||||||
|
var types []string
|
||||||
|
for _, item := range output {
|
||||||
|
im, _ := item.(map[string]interface{})
|
||||||
|
if s, ok := im["type"].(string); ok {
|
||||||
|
types = append(types, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
func effortInt(root map[string]interface{}, key string) (int, bool) {
|
||||||
|
if root == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if value, ok := root[key].(float64); ok {
|
||||||
|
return int(value), true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func logEffortResult(t *testing.T, tc effortProbeCase, result effortProbeResult) {
|
||||||
|
t.Helper()
|
||||||
|
endpoint := "typed-chat"
|
||||||
|
if tc.responses {
|
||||||
|
endpoint = "compat-responses"
|
||||||
|
}
|
||||||
|
t.Logf("endpoint=%s model=%s effort=%s status=%d completion=%d reasoning=%d answer_len=%d output_types=%v request=%s duration_ms=%d error=%s message=%q",
|
||||||
|
endpoint, tc.model, tc.effort, result.status, result.completionTokens, result.reasoningTokens,
|
||||||
|
result.answerLen, result.outputTypes, result.requestRef, result.duration.Milliseconds(),
|
||||||
|
result.errorCode, result.errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeEffortText 遮掩 OCID 并截断,避免错误消息携带租户可定位信息。
|
||||||
|
func sanitizeEffortText(text string) string {
|
||||||
|
text = regexp.MustCompile(`ocid1\.[a-z0-9._-]+`).ReplaceAllString(text, "ocid1.***")
|
||||||
|
if len(text) > 300 {
|
||||||
|
text = text[:300] + "..."
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortEffortRef(ref string) string {
|
||||||
|
if len(ref) > 12 {
|
||||||
|
return ref[:12]
|
||||||
|
}
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
|
||||||
|
func effortEnvOr(key, fallback string) string {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// newEffortProbeResources 装配凭据、区域、inference 客户端与按需模型目录。
|
||||||
|
func newEffortProbeResources(t *testing.T, ctx context.Context) effortProbeResources {
|
||||||
|
t.Helper()
|
||||||
|
cred := effortProbeCredentials(t)
|
||||||
|
region := effortEnvOr("OCI_CACHE_REGION", effortProbeRegion)
|
||||||
|
cred.Region = region
|
||||||
|
real := &RealClient{}
|
||||||
|
ic, err := real.genAiInferenceClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new inference client: %v", err)
|
||||||
|
}
|
||||||
|
models, err := real.ListGenAiModels(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list models: %s", sanitizeEffortText(err.Error()))
|
||||||
|
}
|
||||||
|
index := make(map[string]string, len(models))
|
||||||
|
for _, m := range models {
|
||||||
|
index[m.Name] = m.Ocid
|
||||||
|
}
|
||||||
|
return effortProbeResources{cred: cred, region: region, modelOCID: index, inference: ic}
|
||||||
|
}
|
||||||
|
|
||||||
|
// effortProbeCredentials 优先从面板数据库(只读)取渠道凭据,否则退回本地 ini 测试凭据。
|
||||||
|
func effortProbeCredentials(t *testing.T) Credentials {
|
||||||
|
t.Helper()
|
||||||
|
if dbPath := os.Getenv("OCI_CACHE_DB"); dbPath != "" {
|
||||||
|
return effortDBCredentials(t, dbPath)
|
||||||
|
}
|
||||||
|
return loadTestCredentials(t, effortEnvOr("OCI_TEST_KEY", "试用期"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func effortDBCredentials(t *testing.T, dbPath string) Credentials {
|
||||||
|
t.Helper()
|
||||||
|
dsn := fmt.Sprintf("file:%s?mode=ro", dbPath)
|
||||||
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open read-only probe database: %v", err)
|
||||||
|
}
|
||||||
|
cipher, err := projectcrypto.NewCipher(os.Getenv("DATA_KEY"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create data cipher: %v", err)
|
||||||
|
}
|
||||||
|
var config model.OciConfig
|
||||||
|
if err := db.First(&config, effortEnvOr("OCI_CACHE_CONFIG_ID", "6")).Error; err != nil {
|
||||||
|
t.Fatalf("load OCI config: %v", err)
|
||||||
|
}
|
||||||
|
return decryptEffortCredentials(t, db, cipher, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decryptEffortCredentials(t *testing.T, db *gorm.DB, cipher *projectcrypto.Cipher, config model.OciConfig) Credentials {
|
||||||
|
t.Helper()
|
||||||
|
privateKey, err := cipher.DecryptString(config.PrivateKeyEnc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decrypt OCI config %d private key: %v", config.ID, err)
|
||||||
|
}
|
||||||
|
passphrase := ""
|
||||||
|
if config.PassphraseEnc != "" {
|
||||||
|
if passphrase, err = cipher.DecryptString(config.PassphraseEnc); err != nil {
|
||||||
|
t.Fatalf("decrypt OCI config %d passphrase: %v", config.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Credentials{TenancyOCID: config.TenancyOCID, UserOCID: config.UserOCID,
|
||||||
|
Fingerprint: config.Fingerprint, Region: config.Region, PrivateKey: privateKey,
|
||||||
|
Passphrase: passphrase, Proxy: loadEffortProxy(t, db, cipher, config.ProxyID)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadEffortProxy(t *testing.T, db *gorm.DB, cipher *projectcrypto.Cipher, proxyID *uint) *ProxySpec {
|
||||||
|
t.Helper()
|
||||||
|
if proxyID == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var proxy model.Proxy
|
||||||
|
if err := db.First(&proxy, *proxyID).Error; err != nil {
|
||||||
|
t.Fatalf("load proxy %d: %v", *proxyID, err)
|
||||||
|
}
|
||||||
|
password := ""
|
||||||
|
if proxy.PasswordEnc != "" {
|
||||||
|
decrypted, err := cipher.DecryptString(proxy.PasswordEnc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decrypt proxy %d password: %v", *proxyID, err)
|
||||||
|
}
|
||||||
|
password = decrypted
|
||||||
|
}
|
||||||
|
return &ProxySpec{Type: proxy.Type, Host: proxy.Host, Port: proxy.Port,
|
||||||
|
Username: proxy.Username, Password: password}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseEffortCases 断言自定义用例解析:端点后缀、空项与畸形项跳过。
|
||||||
|
func TestParseEffortCases(t *testing.T) {
|
||||||
|
got := parseEffortCases("a=low, b=high@responses ,bad,=x,c=")
|
||||||
|
want := []effortProbeCase{{model: "a", effort: "low", responses: true}, {model: "b", effort: "high", responses: true}}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("parseEffortCases = %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("case %d = %+v, want %+v", i, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseEffortPayload 断言三种 usage 形态与 output 类型序列的解析。
|
||||||
|
func TestParseEffortPayload(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
payload string
|
||||||
|
completion int
|
||||||
|
reasoning int
|
||||||
|
types int
|
||||||
|
}{
|
||||||
|
{"typed 驼峰", `{"chatResponse":{"choices":[{"message":{"content":[{"type":"TEXT","text":"24"}]}}],"usage":{"completionTokens":5,"completionTokensDetails":{"reasoningTokens":9}}}}`, 5, 9, 0},
|
||||||
|
{"responses 面", `{"output":[{"type":"reasoning"},{"type":"message","content":[{"type":"output_text","text":"24"}]}],"usage":{"output_tokens":7,"output_tokens_details":{"reasoning_tokens":3}}}`, 7, 3, 2},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var result effortProbeResult
|
||||||
|
parseEffortPayload(&result, []byte(tc.payload))
|
||||||
|
if result.completionTokens != tc.completion || result.reasoningTokens != tc.reasoning || len(result.outputTypes) != tc.types {
|
||||||
|
t.Fatalf("parse = %+v, want completion=%d reasoning=%d types=%d", result, tc.completion, tc.reasoning, tc.types)
|
||||||
|
}
|
||||||
|
if result.answerLen != 2 {
|
||||||
|
t.Fatalf("answerLen = %d, want 2", result.answerLen)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RerankRank 是重排结果的一项:index 指向入参 documents 下标。
|
||||||
|
type RerankRank struct {
|
||||||
|
Index int
|
||||||
|
Score float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// GuardrailCategory 是内容审核分类得分(OVERALL / BLOCKLIST)。
|
||||||
|
type GuardrailCategory struct {
|
||||||
|
Name string
|
||||||
|
Score float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// GuardrailPiiHit 是一处 PII 命中(片段原文与位置)。
|
||||||
|
type GuardrailPiiHit struct {
|
||||||
|
Text string
|
||||||
|
Label string
|
||||||
|
Score float64
|
||||||
|
Offset int
|
||||||
|
Length int
|
||||||
|
}
|
||||||
|
|
||||||
|
// GuardrailsOutcome 汇总 ApplyGuardrails 三能力结果。
|
||||||
|
type GuardrailsOutcome struct {
|
||||||
|
Categories []GuardrailCategory
|
||||||
|
Pii []GuardrailPiiHit
|
||||||
|
PromptInjectionScore *float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenAiRerank 实现 Client:文档重排(on-demand serving),返回按相关度排序的下标与得分。
|
||||||
|
func (c *RealClient) GenAiRerank(ctx context.Context, cred Credentials, region, modelOcid, query string, documents []string, topN *int) ([]RerankRank, error) {
|
||||||
|
ic, err := c.genAiInferenceClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := ic.RerankText(ctx, generativeaiinference.RerankTextRequest{
|
||||||
|
RerankTextDetails: generativeaiinference.RerankTextDetails{
|
||||||
|
CompartmentId: &cred.TenancyOCID,
|
||||||
|
ServingMode: generativeaiinference.OnDemandServingMode{ModelId: &modelOcid},
|
||||||
|
Input: &query,
|
||||||
|
Documents: documents,
|
||||||
|
TopN: topN,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("genai rerank: %w", err)
|
||||||
|
}
|
||||||
|
ranks := make([]RerankRank, 0, len(resp.DocumentRanks))
|
||||||
|
for _, r := range resp.DocumentRanks {
|
||||||
|
if r.Index == nil || r.RelevanceScore == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ranks = append(ranks, RerankRank{Index: *r.Index, Score: *r.RelevanceScore})
|
||||||
|
}
|
||||||
|
return ranks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenAiApplyGuardrails 实现 Client:对单条文本执行内容审核 / PII / 提示注入三检测。
|
||||||
|
func (c *RealClient) GenAiApplyGuardrails(ctx context.Context, cred Credentials, region, text string) (*GuardrailsOutcome, error) {
|
||||||
|
ic, err := c.genAiInferenceClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := ic.ApplyGuardrails(ctx, generativeaiinference.ApplyGuardrailsRequest{
|
||||||
|
ApplyGuardrailsDetails: generativeaiinference.ApplyGuardrailsDetails{
|
||||||
|
CompartmentId: &cred.TenancyOCID,
|
||||||
|
Input: generativeaiinference.GuardrailsTextInput{Content: common.String(text)},
|
||||||
|
GuardrailConfigs: &generativeaiinference.GuardrailConfigs{
|
||||||
|
ContentModerationConfig: &generativeaiinference.ContentModerationConfiguration{Categories: []string{"OVERALL"}},
|
||||||
|
PersonallyIdentifiableInformationConfig: &generativeaiinference.PersonallyIdentifiableInformationConfiguration{Types: []string{}},
|
||||||
|
PromptInjectionConfig: &generativeaiinference.PromptInjectionConfiguration{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("genai guardrails: %w", err)
|
||||||
|
}
|
||||||
|
return guardrailsOutcome(resp.Results), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardrailsOutcome 把 SDK 结果换算为 IR;空段置空切片,得分缺失跳过。
|
||||||
|
func guardrailsOutcome(r *generativeaiinference.GuardrailsResults) *GuardrailsOutcome {
|
||||||
|
out := &GuardrailsOutcome{}
|
||||||
|
if r == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if r.ContentModeration != nil {
|
||||||
|
for _, cat := range r.ContentModeration.Categories {
|
||||||
|
if cat.Name == nil || cat.Score == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Categories = append(out.Categories, GuardrailCategory{Name: *cat.Name, Score: *cat.Score})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, hit := range r.PersonallyIdentifiableInformation {
|
||||||
|
h := GuardrailPiiHit{}
|
||||||
|
if hit.Text != nil {
|
||||||
|
h.Text = *hit.Text
|
||||||
|
}
|
||||||
|
if hit.Label != nil {
|
||||||
|
h.Label = *hit.Label
|
||||||
|
}
|
||||||
|
if hit.Score != nil {
|
||||||
|
h.Score = *hit.Score
|
||||||
|
}
|
||||||
|
if hit.Offset != nil {
|
||||||
|
h.Offset = *hit.Offset
|
||||||
|
}
|
||||||
|
if hit.Length != nil {
|
||||||
|
h.Length = *hit.Length
|
||||||
|
}
|
||||||
|
out.Pii = append(out.Pii, h)
|
||||||
|
}
|
||||||
|
if r.PromptInjection != nil && r.PromptInjection.Score != nil {
|
||||||
|
out.PromptInjectionScore = r.PromptInjection.Score
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// compatResponsesLimit 限制直通响应体大小;web_search 输出含多段引用,给足余量。
|
||||||
|
const compatResponsesLimit = int64(8 << 20)
|
||||||
|
|
||||||
|
// dispatcherWithTimeout 把 dispatcher 换成指定总超时的拷贝(保留 Transport,
|
||||||
|
// 代理链路不受影响);timeout=0 表示无总超时(流式读 body 不能有总时限)。
|
||||||
|
// 非 *http.Client 的自定义 dispatcher 保持原样,维持既有超时行为。
|
||||||
|
func dispatcherWithTimeout(d common.HTTPRequestDispatcher, timeout time.Duration) common.HTTPRequestDispatcher {
|
||||||
|
hc, ok := d.(*http.Client)
|
||||||
|
if !ok {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return &http.Client{Transport: hc.Transport, Timeout: timeout}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build compat responses request: %w", err)
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("CompartmentId", 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)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
payload, err := io.ReadAll(io.LimitReader(response.Body, compatResponsesLimit))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read compat responses body: %w", err)
|
||||||
|
}
|
||||||
|
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`,
|
||||||
|
// 建立成功(2xx)返回 SSE body(调用方负责 Close);建立失败返回 SDK ServiceError,
|
||||||
|
// 与既有渠道切换/熔断错误分类兼容。请求体须由调用方置 stream:true。
|
||||||
|
// 总超时置 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)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
client := ic.BaseClient
|
||||||
|
common.UpdateEndpointTemplateForOptions(&client)
|
||||||
|
common.SetMissingTemplateParams(&client)
|
||||||
|
client.HTTPClient = dispatcherWithTimeout(client.HTTPClient, 0)
|
||||||
|
request, err := newCompatResponsesRequest(ctx, cred, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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 应已取消")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -38,3 +38,33 @@ func TestToGenAiModelRetiredFilter(t *testing.T) {
|
|||||||
t.Error("正常模型应入池")
|
t.Error("正常模型应入池")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDedupGenAiModelsFineTunePreference(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 10, 0, 0, 0, 0, time.UTC)
|
||||||
|
chat := []generativeai.ModelCapabilityEnum{generativeai.ModelCapabilityChat}
|
||||||
|
chatFT := []generativeai.ModelCapabilityEnum{generativeai.ModelCapabilityChat, generativeai.ModelCapabilityFineTune}
|
||||||
|
mk := func(id, name string, caps []generativeai.ModelCapabilityEnum) generativeai.ModelSummary {
|
||||||
|
return generativeai.ModelSummary{
|
||||||
|
Id: common.String(id), DisplayName: common.String(name), Vendor: common.String("meta"),
|
||||||
|
Capabilities: caps, LifecycleState: generativeai.ModelLifecycleStateActive,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const name = "meta.llama-3-70b-instruct"
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
items []generativeai.ModelSummary
|
||||||
|
want string // 期望保留的 OCID
|
||||||
|
}{
|
||||||
|
{"基座在前、纯对话在后:保留纯对话条目", []generativeai.ModelSummary{mk("o-ft", name, chatFT), mk("o-od", name, chat)}, "o-od"},
|
||||||
|
{"纯对话在前、基座在后:保留纯对话条目", []generativeai.ModelSummary{mk("o-od", name, chat), mk("o-ft", name, chatFT)}, "o-od"},
|
||||||
|
{"仅基座条目:保留不误删", []generativeai.ModelSummary{mk("o-ft", name, chatFT)}, "o-ft"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := dedupGenAiModels(tt.items, now)
|
||||||
|
if len(got) != 1 || got[0].Ocid != tt.want {
|
||||||
|
t.Errorf("dedupGenAiModels() = %+v, want 仅保留 %s", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// compatSpeechLimit 限制 TTS 音频响应体大小(长文本 mp3 给足余量)。
|
||||||
|
const compatSpeechLimit = int64(64 << 20)
|
||||||
|
|
||||||
|
// GenAiCompatSpeech 实现 Client:把 OpenAI Audio Speech 请求体直通到 OCI
|
||||||
|
// 兼容面 `/openai/v1/audio/speech`(IAM 签名,BasePath 置空——该面与
|
||||||
|
// /20231130/actions 面并存,实测仅前者承载 TTS)。返回音频字节与 Content-Type。
|
||||||
|
func (c *RealClient) GenAiCompatSpeech(ctx context.Context, cred Credentials, region string, body []byte) ([]byte, string, error) {
|
||||||
|
ic, err := c.genAiInferenceClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
client := ic.BaseClient
|
||||||
|
common.UpdateEndpointTemplateForOptions(&client)
|
||||||
|
common.SetMissingTemplateParams(&client)
|
||||||
|
client.BasePath = ""
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "/openai/v1/audio/speech", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("build compat speech request: %w", err)
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("opc-compartment-id", cred.TenancyOCID)
|
||||||
|
response, err := client.Call(ctx, request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
payload, err := io.ReadAll(io.LimitReader(response.Body, compatSpeechLimit))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("read compat speech body: %w", err)
|
||||||
|
}
|
||||||
|
return payload, response.Header.Get("Content-Type"), nil
|
||||||
|
}
|
||||||
+10
-176
@@ -1,193 +1,27 @@
|
|||||||
package oci
|
package oci
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
|
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
|
||||||
|
|
||||||
"oci-portal/internal/aiwire"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestIrToSDK 断言 IR→GENERIC 的角色拆装、采样参数与工具映射。
|
// TestSdkUsageCachedTokens 断言 embed 用量映射:缓存命中挂 details,仅命中 >0 时出现。
|
||||||
func TestIrToSDK(t *testing.T) {
|
|
||||||
temp, mt := 0.7, 100
|
|
||||||
ir := aiwire.ChatRequest{
|
|
||||||
Model: "meta.llama-3.3-70b-instruct",
|
|
||||||
Messages: []aiwire.ChatMessage{
|
|
||||||
{Role: "system", Content: aiwire.NewTextContent("你是助手")},
|
|
||||||
{Role: "user", Content: aiwire.NewTextContent("你好")},
|
|
||||||
{Role: "assistant", ToolCalls: []aiwire.ToolCall{{ID: "c1", Type: "function", Function: aiwire.FunctionCall{Name: "get_weather", Arguments: `{"city":"东京"}`}}}},
|
|
||||||
{Role: "tool", ToolCallID: "c1", Content: aiwire.NewTextContent("晴")},
|
|
||||||
},
|
|
||||||
Temperature: &temp,
|
|
||||||
MaxTokens: &mt,
|
|
||||||
Stop: aiwire.StringList{"END"},
|
|
||||||
Tools: []aiwire.Tool{{Type: "function", Function: aiwire.FunctionDef{Name: "get_weather", Parameters: json.RawMessage(`{"type":"object"}`)}}},
|
|
||||||
ToolChoice: json.RawMessage(`"auto"`),
|
|
||||||
}
|
|
||||||
req := irToSDK(ir, true)
|
|
||||||
if len(req.Messages) != 4 {
|
|
||||||
t.Fatalf("messages = %d, want 4", len(req.Messages))
|
|
||||||
}
|
|
||||||
if _, ok := req.Messages[0].(generativeaiinference.SystemMessage); !ok {
|
|
||||||
t.Fatalf("messages[0] = %T, want SystemMessage", req.Messages[0])
|
|
||||||
}
|
|
||||||
am, ok := req.Messages[2].(generativeaiinference.AssistantMessage)
|
|
||||||
if !ok || len(am.ToolCalls) != 1 {
|
|
||||||
t.Fatalf("assistant tool calls 未映射: %T %+v", req.Messages[2], am)
|
|
||||||
}
|
|
||||||
tm, ok := req.Messages[3].(generativeaiinference.ToolMessage)
|
|
||||||
if !ok || deref(tm.ToolCallId) != "c1" {
|
|
||||||
t.Fatalf("tool message 未映射 toolCallId: %+v", tm)
|
|
||||||
}
|
|
||||||
if req.Temperature == nil || *req.Temperature != 0.7 {
|
|
||||||
t.Fatalf("temperature 未直通")
|
|
||||||
}
|
|
||||||
if req.MaxTokens == nil || *req.MaxTokens != 100 || len(req.Stop) != 1 {
|
|
||||||
t.Fatalf("maxTokens/stop 未直通")
|
|
||||||
}
|
|
||||||
if _, ok := req.ToolChoice.(generativeaiinference.ToolChoiceAuto); !ok {
|
|
||||||
t.Fatalf("toolChoice = %T, want auto", req.ToolChoice)
|
|
||||||
}
|
|
||||||
if len(req.Tools) != 1 || req.IsStream == nil || !*req.IsStream || req.StreamOptions == nil {
|
|
||||||
t.Fatalf("tools/stream 选项未映射")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSdkToIR 断言响应文本、工具调用与用量的反向映射。
|
|
||||||
func TestSdkToIR(t *testing.T) {
|
|
||||||
text, fr := "东京晴", "tool_calls"
|
|
||||||
idx, pt, ct, tt := 0, 10, 5, 15
|
|
||||||
id, name, args := "c1", "get_weather", `{"city":"东京"}`
|
|
||||||
resp := generativeaiinference.GenericChatResponse{
|
|
||||||
Choices: []generativeaiinference.ChatChoice{{
|
|
||||||
Index: &idx,
|
|
||||||
Message: generativeaiinference.AssistantMessage{
|
|
||||||
Content: []generativeaiinference.ChatContent{generativeaiinference.TextContent{Text: &text}},
|
|
||||||
ToolCalls: []generativeaiinference.ToolCall{generativeaiinference.FunctionCall{Id: &id, Name: &name, Arguments: &args}},
|
|
||||||
},
|
|
||||||
FinishReason: &fr,
|
|
||||||
}},
|
|
||||||
Usage: &generativeaiinference.Usage{PromptTokens: &pt, CompletionTokens: &ct, TotalTokens: &tt},
|
|
||||||
}
|
|
||||||
out := sdkToIR(resp, "m1")
|
|
||||||
if len(out.Choices) != 1 || out.Choices[0].Message.Content.JoinText() != "东京晴" {
|
|
||||||
t.Fatalf("文本未映射: %+v", out)
|
|
||||||
}
|
|
||||||
if out.Choices[0].FinishReason != "tool_calls" || len(out.Choices[0].Message.ToolCalls) != 1 {
|
|
||||||
t.Fatalf("finishReason/toolCalls 未映射: %+v", out.Choices[0])
|
|
||||||
}
|
|
||||||
if out.Usage == nil || out.Usage.TotalTokens != 15 {
|
|
||||||
t.Fatalf("usage 未映射: %+v", out.Usage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestParseGenAiEvent 断言流事件宽容解析:文本增量、结束原因与 usage 事件。
|
|
||||||
func TestParseGenAiEvent(t *testing.T) {
|
|
||||||
chunk, ok := parseGenAiEvent([]byte(`{"index":0,"message":{"role":"ASSISTANT","content":[{"type":"TEXT","text":"你"}]}}`), "m1")
|
|
||||||
if !ok || len(chunk.Choices) != 1 || chunk.Choices[0].Delta.Content != "你" {
|
|
||||||
t.Fatalf("文本增量解析失败: %+v", chunk)
|
|
||||||
}
|
|
||||||
chunk, ok = parseGenAiEvent([]byte(`{"finishReason":"stop","usage":{"promptTokens":3,"completionTokens":2,"totalTokens":5}}`), "m1")
|
|
||||||
if !ok || chunk.Choices[0].FinishReason == nil || *chunk.Choices[0].FinishReason != "stop" {
|
|
||||||
t.Fatalf("finishReason 解析失败: %+v", chunk)
|
|
||||||
}
|
|
||||||
if chunk.Usage == nil || chunk.Usage.TotalTokens != 5 {
|
|
||||||
t.Fatalf("usage 解析失败: %+v", chunk.Usage)
|
|
||||||
}
|
|
||||||
if _, ok := parseGenAiEvent([]byte(`{}`), "m1"); ok {
|
|
||||||
t.Fatal("空事件应返回 ok=false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSdkUsageCachedTokens(t *testing.T) {
|
func TestSdkUsageCachedTokens(t *testing.T) {
|
||||||
p, c, tot, cached := 10, 5, 15, 8
|
p, c, tot, cached := 10, 5, 15, 8
|
||||||
u := sdkUsageToIR(&generativeaiinference.Usage{PromptTokens: &p, CompletionTokens: &c, TotalTokens: &tot,
|
u := sdkUsageToIR(&generativeaiinference.Usage{PromptTokens: &p, CompletionTokens: &c, TotalTokens: &tot,
|
||||||
PromptTokensDetails: &generativeaiinference.PromptTokensDetails{CachedTokens: &cached}})
|
PromptTokensDetails: &generativeaiinference.PromptTokensDetails{CachedTokens: &cached}})
|
||||||
if u.CachedTokens() != 8 {
|
if u.PromptTokens != 10 || u.CompletionTokens != 5 || u.TotalTokens != 15 {
|
||||||
t.Errorf("CachedTokens = %d, want 8", u.CachedTokens())
|
t.Fatalf("usage 映射错误: %+v", u)
|
||||||
}
|
}
|
||||||
if sdkUsageToIR(&generativeaiinference.Usage{PromptTokens: &p}).CachedTokens() != 0 {
|
if u.PromptTokensDetails == nil || u.PromptTokensDetails.CachedTokens != 8 {
|
||||||
t.Error("无细分时 CachedTokens 应为 0")
|
t.Fatalf("cachedTokens 未映射: %+v", u.PromptTokensDetails)
|
||||||
}
|
}
|
||||||
}
|
zero := 0
|
||||||
|
if got := sdkUsageToIR(&generativeaiinference.Usage{PromptTokens: &p, PromptTokensDetails: &generativeaiinference.PromptTokensDetails{CachedTokens: &zero}}); got.PromptTokensDetails != nil {
|
||||||
func TestIrToCohereSDK(t *testing.T) {
|
t.Fatalf("零命中不应带 details: %+v", got.PromptTokensDetails)
|
||||||
ir := aiwire.ChatRequest{
|
|
||||||
Model: "cohere.command-r-plus",
|
|
||||||
Messages: []aiwire.ChatMessage{
|
|
||||||
{Role: "system", Content: aiwire.NewTextContent("你是助手")},
|
|
||||||
{Role: "user", Content: aiwire.NewTextContent("第一问")},
|
|
||||||
{Role: "assistant", Content: aiwire.NewTextContent("第一答")},
|
|
||||||
{Role: "user", Content: aiwire.NewTextContent("第二问")},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
req, err := irToCohereSDK(ir, true)
|
if sdkUsageToIR(nil) != nil {
|
||||||
if err != nil {
|
t.Fatal("nil usage 应返回 nil")
|
||||||
t.Fatalf("irToCohereSDK: %v", err)
|
|
||||||
}
|
|
||||||
if *req.Message != "第二问" || *req.PreambleOverride != "你是助手" || len(req.ChatHistory) != 2 {
|
|
||||||
t.Errorf("拆装错误: msg=%q preamble=%v history=%d", *req.Message, req.PreambleOverride, len(req.ChatHistory))
|
|
||||||
}
|
|
||||||
if _, ok := req.ChatHistory[0].(generativeaiinference.CohereUserMessage); !ok {
|
|
||||||
t.Errorf("history[0] 应为 user: %T", req.ChatHistory[0])
|
|
||||||
}
|
|
||||||
if _, ok := req.ChatHistory[1].(generativeaiinference.CohereChatBotMessage); !ok {
|
|
||||||
t.Errorf("history[1] 应为 chatbot: %T", req.ChatHistory[1])
|
|
||||||
}
|
|
||||||
if req.IsStream == nil || !*req.IsStream {
|
|
||||||
t.Error("IsStream 未设置")
|
|
||||||
}
|
|
||||||
if req.StreamOptions == nil || req.StreamOptions.IsIncludeUsage == nil || !*req.StreamOptions.IsIncludeUsage {
|
|
||||||
t.Error("流式应默认开启 usage 回传(StreamOptions.IsIncludeUsage)")
|
|
||||||
}
|
|
||||||
// 工具定义降级为扁平参数表(仅取 JSON Schema 顶层 properties)
|
|
||||||
ir.Tools = []aiwire.Tool{{Type: "function", Function: aiwire.FunctionDef{
|
|
||||||
Name: "get_weather", Parameters: json.RawMessage(`{"type":"object","properties":{"city":{"type":"string","description":"城市"}},"required":["city"]}`)}}}
|
|
||||||
req2, err := irToCohereSDK(ir, false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("工具请求应支持: %v", err)
|
|
||||||
}
|
|
||||||
tool := req2.Tools[0]
|
|
||||||
if *tool.Name != "get_weather" || *tool.Description != "get_weather" {
|
|
||||||
t.Errorf("tool = %+v", tool)
|
|
||||||
}
|
|
||||||
if pd, ok := tool.ParameterDefinitions["city"]; !ok || *pd.Type != "string" || pd.IsRequired == nil || !*pd.IsRequired {
|
|
||||||
t.Errorf("param city = %+v", pd)
|
|
||||||
}
|
|
||||||
// 图片输入仍拒绝
|
|
||||||
ir.Tools = nil
|
|
||||||
ir.Messages = append(ir.Messages, aiwire.ChatMessage{Role: "user", Content: aiwire.NewPartsContent([]aiwire.ContentPart{
|
|
||||||
{Type: "image_url", ImageURL: &aiwire.ImageURL{URL: "data:image/png;base64,xx"}}})})
|
|
||||||
if _, err := irToCohereSDK(ir, false); err == nil {
|
|
||||||
t.Error("图片输入应被拒绝")
|
|
||||||
}
|
|
||||||
// 无 user 消息被拒
|
|
||||||
if _, err := irToCohereSDK(aiwire.ChatRequest{Model: "cohere.x", Messages: []aiwire.ChatMessage{{Role: "system", Content: aiwire.NewTextContent("s")}}}, false); err == nil {
|
|
||||||
t.Error("无 user 消息应被拒绝")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCohereSDKToIR(t *testing.T) {
|
|
||||||
text := "回答"
|
|
||||||
resp := generativeaiinference.CohereChatResponse{
|
|
||||||
Text: &text,
|
|
||||||
FinishReason: generativeaiinference.CohereChatResponseFinishReasonComplete,
|
|
||||||
}
|
|
||||||
out := cohereSDKToIR(resp, "cohere.command-r-plus")
|
|
||||||
if out.Choices[0].Message.Content.JoinText() != "回答" || out.Choices[0].FinishReason != "stop" {
|
|
||||||
t.Errorf("cohereSDKToIR = %+v", out.Choices[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseGenAiEventCohere(t *testing.T) {
|
|
||||||
chunk, ok := parseGenAiEvent([]byte(`{"apiFormat":"COHERE","text":"你好"}`), "cohere.command-r")
|
|
||||||
if !ok || chunk.Choices[0].Delta.Content != "你好" {
|
|
||||||
t.Errorf("cohere text 事件解析 = %+v, %v", chunk, ok)
|
|
||||||
}
|
|
||||||
chunk, ok = parseGenAiEvent([]byte(`{"apiFormat":"COHERE","finishReason":"COMPLETE","usage":{"promptTokens":3,"completionTokens":5,"totalTokens":8}}`), "cohere.command-r")
|
|
||||||
if !ok || *chunk.Choices[0].FinishReason != "stop" || chunk.Usage.TotalTokens != 8 {
|
|
||||||
t.Errorf("cohere 终帧解析 = %+v, %v", chunk, ok)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -397,9 +397,10 @@ func fillInstanceIPs(ctx context.Context, cc core.ComputeClient, vn core.Virtual
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var (
|
var (
|
||||||
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"oci-portal/internal/aiwire"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrAiUnsupportedBlock 表示请求含网关无法承接的内容块(仅支持文本与图片)。
|
|
||||||
var ErrAiUnsupportedBlock = fmt.Errorf("暂不支持文本与图片以外的内容块")
|
|
||||||
|
|
||||||
// AnthropicToIR 把 Anthropic Messages 请求转为 IR(OpenAI 线格式)。
|
|
||||||
// tool_result 块拆为独立 tool 角色消息(一拆多),顶层 system 变首条 system 消息。
|
|
||||||
func AnthropicToIR(req aiwire.MessagesRequest) (aiwire.ChatRequest, error) {
|
|
||||||
ir := aiwire.ChatRequest{
|
|
||||||
Model: req.Model,
|
|
||||||
Temperature: req.Temperature,
|
|
||||||
TopP: req.TopP,
|
|
||||||
TopK: req.TopK,
|
|
||||||
Stop: req.StopSequences,
|
|
||||||
Stream: req.Stream,
|
|
||||||
Tools: anthTools(req.Tools),
|
|
||||||
ToolChoice: anthToolChoice(req.ToolChoice),
|
|
||||||
}
|
|
||||||
if req.MaxTokens > 0 {
|
|
||||||
mt := req.MaxTokens
|
|
||||||
ir.MaxTokens = &mt
|
|
||||||
}
|
|
||||||
if sys := req.SystemText(); sys != "" {
|
|
||||||
ir.Messages = append(ir.Messages, aiwire.ChatMessage{Role: "system", Content: aiwire.NewTextContent(sys)})
|
|
||||||
}
|
|
||||||
for _, m := range req.Messages {
|
|
||||||
msgs, err := anthMessageToIR(m)
|
|
||||||
if err != nil {
|
|
||||||
return ir, err
|
|
||||||
}
|
|
||||||
ir.Messages = append(ir.Messages, msgs...)
|
|
||||||
}
|
|
||||||
return ir, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// anthMessageToIR 拆解单条 Anthropic 消息;user 消息里的 tool_result 前置为独立 tool 消息,
|
|
||||||
// image 块转为 IR image_url 部件。
|
|
||||||
func anthMessageToIR(m aiwire.AnthMessage) ([]aiwire.ChatMessage, error) {
|
|
||||||
var out []aiwire.ChatMessage
|
|
||||||
var parts []aiwire.ContentPart
|
|
||||||
var toolCalls []aiwire.ToolCall
|
|
||||||
hasImage := false
|
|
||||||
for _, b := range m.Content.AllBlocks() {
|
|
||||||
switch b.Type {
|
|
||||||
case "text":
|
|
||||||
parts = append(parts, aiwire.ContentPart{Type: "text", Text: b.Text})
|
|
||||||
case "image":
|
|
||||||
p, err := anthImagePart(b.Source)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
parts, hasImage = append(parts, p), true
|
|
||||||
case "tool_use":
|
|
||||||
toolCalls = append(toolCalls, aiwire.ToolCall{ID: b.ID, Type: "function",
|
|
||||||
Function: aiwire.FunctionCall{Name: b.Name, Arguments: string(b.Input)}})
|
|
||||||
case "tool_result":
|
|
||||||
out = append(out, aiwire.ChatMessage{Role: "tool", ToolCallID: b.ToolUseID,
|
|
||||||
Content: aiwire.NewTextContent(b.ResultText())})
|
|
||||||
case "thinking", "redacted_thinking":
|
|
||||||
// 一期忽略 thinking 块
|
|
||||||
default:
|
|
||||||
return nil, ErrAiUnsupportedBlock
|
|
||||||
}
|
|
||||||
}
|
|
||||||
content := partsContent(parts, hasImage)
|
|
||||||
if hasImage || content.JoinText() != "" || len(toolCalls) > 0 {
|
|
||||||
out = append(out, aiwire.ChatMessage{Role: m.Role, Content: content, ToolCalls: toolCalls})
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// anthImagePart 把 Anthropic image 块转为 IR image_url 部件(base64 → data URI)。
|
|
||||||
func anthImagePart(source json.RawMessage) (aiwire.ContentPart, error) {
|
|
||||||
var src struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
MediaType string `json:"media_type"`
|
|
||||||
Data string `json:"data"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(source, &src); err != nil {
|
|
||||||
return aiwire.ContentPart{}, fmt.Errorf("image source 解析失败: %w", err)
|
|
||||||
}
|
|
||||||
switch src.Type {
|
|
||||||
case "base64":
|
|
||||||
if src.MediaType == "" || src.Data == "" {
|
|
||||||
return aiwire.ContentPart{}, fmt.Errorf("image source 缺少 media_type 或 data")
|
|
||||||
}
|
|
||||||
url := "data:" + src.MediaType + ";base64," + src.Data
|
|
||||||
return aiwire.ContentPart{Type: "image_url", ImageURL: &aiwire.ImageURL{URL: url}}, nil
|
|
||||||
case "url":
|
|
||||||
if src.URL == "" {
|
|
||||||
return aiwire.ContentPart{}, fmt.Errorf("image source 缺少 url")
|
|
||||||
}
|
|
||||||
return aiwire.ContentPart{Type: "image_url", ImageURL: &aiwire.ImageURL{URL: src.URL}}, nil
|
|
||||||
}
|
|
||||||
return aiwire.ContentPart{}, fmt.Errorf("不支持的 image source 类型 %q", src.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
// partsContent 无图片时退回字符串形态(与上游线格式习惯一致),含图片时保留块数组。
|
|
||||||
func partsContent(parts []aiwire.ContentPart, hasImage bool) aiwire.Content {
|
|
||||||
if !hasImage {
|
|
||||||
var sb strings.Builder
|
|
||||||
for _, p := range parts {
|
|
||||||
sb.WriteString(p.Text)
|
|
||||||
}
|
|
||||||
return aiwire.NewTextContent(sb.String())
|
|
||||||
}
|
|
||||||
return aiwire.NewPartsContent(parts)
|
|
||||||
}
|
|
||||||
|
|
||||||
func anthTools(tools []aiwire.AnthTool) []aiwire.Tool {
|
|
||||||
if len(tools) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]aiwire.Tool, 0, len(tools))
|
|
||||||
for _, t := range tools {
|
|
||||||
out = append(out, aiwire.Tool{Type: "function", Function: aiwire.FunctionDef{
|
|
||||||
Name: t.Name, Description: t.Description, Parameters: t.InputSchema}})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// anthToolChoice 映射 {type:auto|any|tool,name} → OpenAI 形态。
|
|
||||||
func anthToolChoice(raw json.RawMessage) json.RawMessage {
|
|
||||||
if len(raw) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var tc struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(raw, &tc) != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
switch tc.Type {
|
|
||||||
case "auto":
|
|
||||||
return json.RawMessage(`"auto"`)
|
|
||||||
case "any":
|
|
||||||
return json.RawMessage(`"required"`)
|
|
||||||
case "tool":
|
|
||||||
b, _ := json.Marshal(map[string]any{"type": "function", "function": map[string]string{"name": tc.Name}})
|
|
||||||
return b
|
|
||||||
case "none":
|
|
||||||
return json.RawMessage(`"none"`)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IRRespToAnthropic 把 IR 非流式响应转为 Anthropic Messages 响应。
|
|
||||||
func IRRespToAnthropic(resp *aiwire.ChatResponse, id string) aiwire.MessagesResponse {
|
|
||||||
out := aiwire.MessagesResponse{ID: id, Type: "message", Role: "assistant", Model: resp.Model, Content: []aiwire.AnthBlock{}}
|
|
||||||
if len(resp.Choices) > 0 {
|
|
||||||
choice := resp.Choices[0]
|
|
||||||
if text := choice.Message.Content.JoinText(); text != "" {
|
|
||||||
out.Content = append(out.Content, aiwire.AnthBlock{Type: "text", Text: text})
|
|
||||||
}
|
|
||||||
for _, tc := range choice.Message.ToolCalls {
|
|
||||||
out.Content = append(out.Content, aiwire.AnthBlock{Type: "tool_use", ID: tc.ID,
|
|
||||||
Name: tc.Function.Name, Input: argsToJSON(tc.Function.Arguments)})
|
|
||||||
}
|
|
||||||
out.StopReason = anthStopReason(choice.FinishReason)
|
|
||||||
}
|
|
||||||
if resp.Usage != nil {
|
|
||||||
out.Usage = aiwire.AnthUsage{InputTokens: resp.Usage.PromptTokens, OutputTokens: resp.Usage.CompletionTokens,
|
|
||||||
CacheReadInputTokens: resp.Usage.CachedTokens()}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// argsToJSON 保证 tool_use.input 是合法 JSON 对象(模型可能产出非法片段)。
|
|
||||||
func argsToJSON(args string) json.RawMessage {
|
|
||||||
trimmed := strings.TrimSpace(args)
|
|
||||||
if trimmed == "" {
|
|
||||||
return json.RawMessage(`{}`)
|
|
||||||
}
|
|
||||||
if json.Valid([]byte(trimmed)) {
|
|
||||||
return json.RawMessage(trimmed)
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(map[string]string{"_raw": args})
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
func anthStopReason(finish string) string {
|
|
||||||
switch finish {
|
|
||||||
case "length":
|
|
||||||
return "max_tokens"
|
|
||||||
case "tool_calls":
|
|
||||||
return "tool_use"
|
|
||||||
default:
|
|
||||||
return "end_turn"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Anthropic 流式状态机 ----
|
|
||||||
|
|
||||||
// AnthEvent 是一条待写出的 Anthropic SSE 事件。
|
|
||||||
type AnthEvent struct {
|
|
||||||
Event string
|
|
||||||
Data any
|
|
||||||
}
|
|
||||||
|
|
||||||
// AnthStream 把 IR chunk 流聚合为 Anthropic 事件序列:
|
|
||||||
// message_start → content_block_start/delta/stop(text 与 tool_use 分块)→ message_delta → message_stop。
|
|
||||||
type AnthStream struct {
|
|
||||||
id, model string
|
|
||||||
started bool
|
|
||||||
blockOpen bool
|
|
||||||
blockIsTool bool
|
|
||||||
toolID string
|
|
||||||
blockIndex int
|
|
||||||
stopReason string
|
|
||||||
usage aiwire.AnthUsage
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAnthStream 构造状态机;id 为响应消息 ID。
|
|
||||||
func NewAnthStream(id, model string) *AnthStream {
|
|
||||||
return &AnthStream{id: id, model: model, blockIndex: -1, stopReason: "end_turn"}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Feed 消费一个 IR chunk,返回应立即写出的事件。
|
|
||||||
func (st *AnthStream) Feed(chunk aiwire.ChatChunk) []AnthEvent {
|
|
||||||
var events []AnthEvent
|
|
||||||
if !st.started {
|
|
||||||
st.started = true
|
|
||||||
events = append(events, st.startEvent())
|
|
||||||
}
|
|
||||||
if chunk.Usage != nil {
|
|
||||||
st.usage = aiwire.AnthUsage{InputTokens: chunk.Usage.PromptTokens, OutputTokens: chunk.Usage.CompletionTokens,
|
|
||||||
CacheReadInputTokens: chunk.Usage.CachedTokens()}
|
|
||||||
}
|
|
||||||
for _, choice := range chunk.Choices {
|
|
||||||
events = append(events, st.feedDelta(choice.Delta)...)
|
|
||||||
if choice.FinishReason != nil && *choice.FinishReason != "" {
|
|
||||||
st.stopReason = anthStopReason(*choice.FinishReason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return events
|
|
||||||
}
|
|
||||||
|
|
||||||
func (st *AnthStream) startEvent() AnthEvent {
|
|
||||||
return AnthEvent{Event: "message_start", Data: map[string]any{
|
|
||||||
"type": "message_start",
|
|
||||||
"message": map[string]any{
|
|
||||||
"id": st.id, "type": "message", "role": "assistant", "model": st.model,
|
|
||||||
"content": []any{}, "stop_reason": nil,
|
|
||||||
"usage": map[string]int{"input_tokens": 0, "output_tokens": 0},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// feedDelta 处理文本与工具调用增量,必要时切块。
|
|
||||||
func (st *AnthStream) feedDelta(d aiwire.Delta) []AnthEvent {
|
|
||||||
var events []AnthEvent
|
|
||||||
if d.Content != "" {
|
|
||||||
if !st.blockOpen || st.blockIsTool {
|
|
||||||
events = append(events, st.openBlock(false, "", "")...)
|
|
||||||
}
|
|
||||||
events = append(events, AnthEvent{Event: "content_block_delta", Data: map[string]any{
|
|
||||||
"type": "content_block_delta", "index": st.blockIndex,
|
|
||||||
"delta": map[string]string{"type": "text_delta", "text": d.Content},
|
|
||||||
}})
|
|
||||||
}
|
|
||||||
for _, tc := range d.ToolCalls {
|
|
||||||
if tc.ID != "" && (!st.blockOpen || !st.blockIsTool || st.toolID != tc.ID) {
|
|
||||||
events = append(events, st.openBlock(true, tc.ID, tc.Function.Name)...)
|
|
||||||
}
|
|
||||||
if tc.Function.Arguments != "" && st.blockOpen && st.blockIsTool {
|
|
||||||
events = append(events, AnthEvent{Event: "content_block_delta", Data: map[string]any{
|
|
||||||
"type": "content_block_delta", "index": st.blockIndex,
|
|
||||||
"delta": map[string]string{"type": "input_json_delta", "partial_json": tc.Function.Arguments},
|
|
||||||
}})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return events
|
|
||||||
}
|
|
||||||
|
|
||||||
// openBlock 关闭当前块并打开新块(text 或 tool_use)。
|
|
||||||
func (st *AnthStream) openBlock(isTool bool, toolID, toolName string) []AnthEvent {
|
|
||||||
var events []AnthEvent
|
|
||||||
if st.blockOpen {
|
|
||||||
events = append(events, st.closeBlockEvent())
|
|
||||||
}
|
|
||||||
st.blockOpen, st.blockIsTool, st.toolID = true, isTool, toolID
|
|
||||||
st.blockIndex++
|
|
||||||
block := map[string]any{"type": "text", "text": ""}
|
|
||||||
if isTool {
|
|
||||||
block = map[string]any{"type": "tool_use", "id": toolID, "name": toolName, "input": map[string]any{}}
|
|
||||||
}
|
|
||||||
events = append(events, AnthEvent{Event: "content_block_start", Data: map[string]any{
|
|
||||||
"type": "content_block_start", "index": st.blockIndex, "content_block": block,
|
|
||||||
}})
|
|
||||||
return events
|
|
||||||
}
|
|
||||||
|
|
||||||
func (st *AnthStream) closeBlockEvent() AnthEvent {
|
|
||||||
return AnthEvent{Event: "content_block_stop", Data: map[string]any{
|
|
||||||
"type": "content_block_stop", "index": st.blockIndex,
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finish 在上游流结束后收尾:关块 → message_delta(stop_reason+usage)→ message_stop。
|
|
||||||
func (st *AnthStream) Finish() []AnthEvent {
|
|
||||||
var events []AnthEvent
|
|
||||||
if !st.started {
|
|
||||||
events = append(events, st.startEvent())
|
|
||||||
st.started = true
|
|
||||||
}
|
|
||||||
if st.blockOpen {
|
|
||||||
events = append(events, st.closeBlockEvent())
|
|
||||||
st.blockOpen = false
|
|
||||||
}
|
|
||||||
events = append(events,
|
|
||||||
AnthEvent{Event: "message_delta", Data: map[string]any{
|
|
||||||
"type": "message_delta",
|
|
||||||
"delta": map[string]any{"stop_reason": st.stopReason, "stop_sequence": nil},
|
|
||||||
"usage": map[string]int{"output_tokens": st.usage.OutputTokens},
|
|
||||||
}},
|
|
||||||
AnthEvent{Event: "message_stop", Data: map[string]any{"type": "message_stop"}},
|
|
||||||
)
|
|
||||||
return events
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage 返回聚合到的用量(供调用日志)。
|
|
||||||
func (st *AnthStream) Usage() aiwire.AnthUsage { return st.usage }
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"oci-portal/internal/aiwire"
|
|
||||||
)
|
|
||||||
|
|
||||||
func mustAnthReq(t *testing.T, body string) aiwire.MessagesRequest {
|
|
||||||
t.Helper()
|
|
||||||
var req aiwire.MessagesRequest
|
|
||||||
if err := json.Unmarshal([]byte(body), &req); err != nil {
|
|
||||||
t.Fatalf("unmarshal anthropic request: %v", err)
|
|
||||||
}
|
|
||||||
return req
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnthropicToIR(t *testing.T) {
|
|
||||||
req := mustAnthReq(t, `{
|
|
||||||
"model": "meta.llama-3.3-70b-instruct", "max_tokens": 128, "system": "你是助手",
|
|
||||||
"messages": [
|
|
||||||
{"role": "user", "content": "东京天气?"},
|
|
||||||
{"role": "assistant", "content": [
|
|
||||||
{"type": "text", "text": "查询中"},
|
|
||||||
{"type": "tool_use", "id": "t1", "name": "get_weather", "input": {"city": "东京"}}
|
|
||||||
]},
|
|
||||||
{"role": "user", "content": [
|
|
||||||
{"type": "tool_result", "tool_use_id": "t1", "content": "晴 25 度"},
|
|
||||||
{"type": "text", "text": "继续"}
|
|
||||||
]}
|
|
||||||
],
|
|
||||||
"tools": [{"name": "get_weather", "input_schema": {"type": "object"}}],
|
|
||||||
"tool_choice": {"type": "any"}
|
|
||||||
}`)
|
|
||||||
ir, err := AnthropicToIR(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("AnthropicToIR: %v", err)
|
|
||||||
}
|
|
||||||
roles := make([]string, 0, len(ir.Messages))
|
|
||||||
for _, m := range ir.Messages {
|
|
||||||
roles = append(roles, m.Role)
|
|
||||||
}
|
|
||||||
want := []string{"system", "user", "assistant", "tool", "user"}
|
|
||||||
if strings.Join(roles, ",") != strings.Join(want, ",") {
|
|
||||||
t.Fatalf("roles = %v, want %v", roles, want)
|
|
||||||
}
|
|
||||||
if ir.Messages[2].ToolCalls[0].Function.Name != "get_weather" {
|
|
||||||
t.Errorf("tool_use 未映射: %+v", ir.Messages[2])
|
|
||||||
}
|
|
||||||
if ir.Messages[3].ToolCallID != "t1" || ir.Messages[3].Content.JoinText() != "晴 25 度" {
|
|
||||||
t.Errorf("tool_result 未拆为 tool 消息: %+v", ir.Messages[3])
|
|
||||||
}
|
|
||||||
if ir.MaxTokens == nil || *ir.MaxTokens != 128 || len(ir.Tools) != 1 {
|
|
||||||
t.Errorf("max_tokens/tools 未直通")
|
|
||||||
}
|
|
||||||
if string(ir.ToolChoice) != `"required"` {
|
|
||||||
t.Errorf("tool_choice any → %s, want required", ir.ToolChoice)
|
|
||||||
}
|
|
||||||
// image 块拒绝
|
|
||||||
bad := mustAnthReq(t, `{"model":"m","max_tokens":1,"messages":[{"role":"user","content":[{"type":"image","source":{}}]}]}`)
|
|
||||||
if _, err := AnthropicToIR(bad); err == nil {
|
|
||||||
t.Error("image 块应被拒绝")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIRRespToAnthropic(t *testing.T) {
|
|
||||||
resp := &aiwire.ChatResponse{
|
|
||||||
Model: "m1",
|
|
||||||
Choices: []aiwire.Choice{{
|
|
||||||
Message: aiwire.ChatMessage{
|
|
||||||
Role: "assistant",
|
|
||||||
Content: aiwire.NewTextContent("查到了"),
|
|
||||||
ToolCalls: []aiwire.ToolCall{{ID: "t1", Type: "function",
|
|
||||||
Function: aiwire.FunctionCall{Name: "get_weather", Arguments: `{"city":"东京"}`}}},
|
|
||||||
},
|
|
||||||
FinishReason: "tool_calls",
|
|
||||||
}},
|
|
||||||
Usage: &aiwire.Usage{PromptTokens: 10, CompletionTokens: 5},
|
|
||||||
}
|
|
||||||
out := IRRespToAnthropic(resp, "msg_1")
|
|
||||||
if len(out.Content) != 2 || out.Content[0].Type != "text" || out.Content[1].Type != "tool_use" {
|
|
||||||
t.Fatalf("content 块 = %+v", out.Content)
|
|
||||||
}
|
|
||||||
if out.StopReason != "tool_use" || out.Usage.InputTokens != 10 || out.Usage.OutputTokens != 5 {
|
|
||||||
t.Errorf("stop_reason/usage 未映射: %+v", out)
|
|
||||||
}
|
|
||||||
if string(out.Content[1].Input) != `{"city":"东京"}` {
|
|
||||||
t.Errorf("tool input = %s", out.Content[1].Input)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// eventTypes 提取事件类型序列便于断言。
|
|
||||||
func eventTypes(events []AnthEvent) string {
|
|
||||||
types := make([]string, 0, len(events))
|
|
||||||
for _, e := range events {
|
|
||||||
types = append(types, e.Event)
|
|
||||||
}
|
|
||||||
return strings.Join(types, ",")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnthStreamTextAndTool(t *testing.T) {
|
|
||||||
st := NewAnthStream("msg_1", "m1")
|
|
||||||
fr := "tool_calls"
|
|
||||||
var events []AnthEvent
|
|
||||||
events = append(events, st.Feed(aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{Content: "你"}}}})...)
|
|
||||||
events = append(events, st.Feed(aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{Content: "好"}}}})...)
|
|
||||||
events = append(events, st.Feed(aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{
|
|
||||||
ToolCalls: []aiwire.ToolCallDelta{{ID: "t1", Function: aiwire.FunctionCallDelta{Name: "get_weather", Arguments: `{"ci`}}},
|
|
||||||
}}}})...)
|
|
||||||
events = append(events, st.Feed(aiwire.ChatChunk{
|
|
||||||
Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{ToolCalls: []aiwire.ToolCallDelta{{ID: "t1", Function: aiwire.FunctionCallDelta{Arguments: `ty":"东京"}`}}}}, FinishReason: &fr}},
|
|
||||||
Usage: &aiwire.Usage{PromptTokens: 8, CompletionTokens: 4},
|
|
||||||
})...)
|
|
||||||
events = append(events, st.Finish()...)
|
|
||||||
|
|
||||||
got := eventTypes(events)
|
|
||||||
want := "message_start,content_block_start,content_block_delta,content_block_delta," +
|
|
||||||
"content_block_stop,content_block_start,content_block_delta,content_block_delta," +
|
|
||||||
"content_block_stop,message_delta,message_stop"
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("事件序列:\n got %s\nwant %s", got, want)
|
|
||||||
}
|
|
||||||
if st.Usage().OutputTokens != 4 {
|
|
||||||
t.Errorf("usage 未聚合: %+v", st.Usage())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnthStreamEmpty(t *testing.T) {
|
|
||||||
st := NewAnthStream("msg_1", "m1")
|
|
||||||
events := st.Finish()
|
|
||||||
if got := eventTypes(events); got != "message_start,message_delta,message_stop" {
|
|
||||||
t.Errorf("空流事件序列 = %s", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+484
-25
@@ -5,12 +5,15 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -57,11 +60,156 @@ type AiGatewayService struct {
|
|||||||
lastTouch map[uint]time.Time
|
lastTouch map[uint]time.Time
|
||||||
// onChannelsChanged 在渠道增删后触发,由 main 装配为探测任务同步钩子
|
// onChannelsChanged 在渠道增删后触发,由 main 装配为探测任务同步钩子
|
||||||
onChannelsChanged func(context.Context)
|
onChannelsChanged func(context.Context)
|
||||||
|
// filterDeprecated 是「过滤弃用模型」开关(内存镜像,持久化在 settings 表)
|
||||||
|
filterDeprecated atomic.Bool
|
||||||
|
// 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 {
|
||||||
return &AiGatewayService{db: db, configs: configs, client: client, lastTouch: map[uint]time.Time{}}
|
s := &AiGatewayService{db: db, configs: configs, client: client, lastTouch: map[uint]time.Time{}}
|
||||||
|
s.filterDeprecated.Store(loadBoolSetting(db, settingAiFilterDeprecated, false))
|
||||||
|
s.streamGuardEnabled.Store(loadBoolSetting(db, settingAiStreamGuardEnabled, true))
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 网关运行时设置的配置键;bool 值存 "1"/"0"。
|
||||||
|
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 返回「过滤弃用模型」开关状态。
|
||||||
|
func (s *AiGatewayService) FilterDeprecated() bool { return s.filterDeprecated.Load() }
|
||||||
|
|
||||||
|
// SetFilterDeprecated 持久化并即时生效开关:开启后已宣布弃用
|
||||||
|
// (deprecated_at 非空,即使未退役)的模型从列表与路由中排除。
|
||||||
|
func (s *AiGatewayService) SetFilterDeprecated(ctx context.Context, on bool) error {
|
||||||
|
if err := s.saveBoolSetting(ctx, settingAiFilterDeprecated, on); err != nil {
|
||||||
|
return fmt.Errorf("保存过滤弃用模型开关: %w", err)
|
||||||
|
}
|
||||||
|
s.filterDeprecated.Store(on)
|
||||||
|
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 注册渠道数量变化钩子(渠道创建/删除成功后调用)。
|
||||||
@@ -79,7 +227,7 @@ func (s *AiGatewayService) fireChannelsChanged(ctx context.Context) {
|
|||||||
|
|
||||||
// CreateKey 生成网关密钥并返回明文(仅此一次);customValue 非空时使用给定值,
|
// CreateKey 生成网关密钥并返回明文(仅此一次);customValue 非空时使用给定值,
|
||||||
// group 非空时该密钥只在同分组渠道内路由。
|
// group 非空时该密钥只在同分组渠道内路由。
|
||||||
func (s *AiGatewayService) CreateKey(ctx context.Context, name, customValue, group string) (string, *model.AiKey, error) {
|
func (s *AiGatewayService) CreateKey(ctx context.Context, name, customValue, group string, models []string) (string, *model.AiKey, error) {
|
||||||
name = strings.TrimSpace(name)
|
name = strings.TrimSpace(name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return "", nil, fmt.Errorf("密钥名称不能为空")
|
return "", nil, fmt.Errorf("密钥名称不能为空")
|
||||||
@@ -95,13 +243,28 @@ func (s *AiGatewayService) CreateKey(ctx context.Context, name, customValue, gro
|
|||||||
if len(raw) < 8 {
|
if len(raw) < 8 {
|
||||||
return "", nil, fmt.Errorf("自定义密钥至少 8 个字符")
|
return "", nil, fmt.Errorf("自定义密钥至少 8 个字符")
|
||||||
}
|
}
|
||||||
key := &model.AiKey{Name: name, KeyHash: hashKey(raw), Tail: raw[len(raw)-4:], Group: strings.TrimSpace(group), Enabled: true}
|
key := &model.AiKey{Name: name, KeyHash: hashKey(raw), Tail: raw[len(raw)-4:], Group: strings.TrimSpace(group), Models: normalizeKeyModels(models), Enabled: true}
|
||||||
if err := s.db.WithContext(ctx).Create(key).Error; err != nil {
|
if err := s.db.WithContext(ctx).Create(key).Error; err != nil {
|
||||||
return "", nil, fmt.Errorf("密钥名称或取值与现有密钥重复")
|
return "", nil, fmt.Errorf("密钥名称或取值与现有密钥重复")
|
||||||
}
|
}
|
||||||
return raw, key, nil
|
return raw, key, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeKeyModels 规范化模型白名单:trim、剔空串、保序去重;结果为空返回 nil(= 不限)。
|
||||||
|
func normalizeKeyModels(models []string) []string {
|
||||||
|
var out []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, m := range models {
|
||||||
|
m = strings.TrimSpace(m)
|
||||||
|
if m == "" || seen[m] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[m] = true
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func hashKey(raw string) string {
|
func hashKey(raw string) string {
|
||||||
sum := sha256.Sum256([]byte(raw))
|
sum := sha256.Sum256([]byte(raw))
|
||||||
return hex.EncodeToString(sum[:])
|
return hex.EncodeToString(sum[:])
|
||||||
@@ -114,8 +277,8 @@ func (s *AiGatewayService) Keys(ctx context.Context) ([]model.AiKey, error) {
|
|||||||
return keys, err
|
return keys, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateKey 修改密钥名称 / 启用状态 / 分组(group 指针非空即覆盖,可置空)。
|
// UpdateKey 修改密钥名称 / 启用状态 / 分组 / 模型白名单(指针非空即覆盖,可置空)。
|
||||||
func (s *AiGatewayService) UpdateKey(ctx context.Context, id uint, name string, enabled *bool, group *string) error {
|
func (s *AiGatewayService) UpdateKey(ctx context.Context, id uint, name string, enabled *bool, group *string, models *[]string) error {
|
||||||
updates := map[string]any{}
|
updates := map[string]any{}
|
||||||
if name = strings.TrimSpace(name); name != "" {
|
if name = strings.TrimSpace(name); name != "" {
|
||||||
updates["name"] = name
|
updates["name"] = name
|
||||||
@@ -126,6 +289,14 @@ func (s *AiGatewayService) UpdateKey(ctx context.Context, id uint, name string,
|
|||||||
if group != nil {
|
if group != nil {
|
||||||
updates["key_group"] = strings.TrimSpace(*group)
|
updates["key_group"] = strings.TrimSpace(*group)
|
||||||
}
|
}
|
||||||
|
if models != nil {
|
||||||
|
// 手动序列化走 map 更新,不依赖 GORM map 路径对 serializer 的支持
|
||||||
|
b, err := json.Marshal(normalizeKeyModels(*models))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("serialize models: %w", err)
|
||||||
|
}
|
||||||
|
updates["models"] = string(b)
|
||||||
|
}
|
||||||
if len(updates) == 0 {
|
if len(updates) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -215,11 +386,34 @@ func valueOr(p *int, def int) int {
|
|||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
|
|
||||||
// Channels 列出全部渠道。
|
// Channels 列出全部渠道并回填各自的模型缓存计数。
|
||||||
func (s *AiGatewayService) Channels(ctx context.Context) ([]model.AiChannel, error) {
|
func (s *AiGatewayService) Channels(ctx context.Context) ([]model.AiChannel, error) {
|
||||||
var chs []model.AiChannel
|
var chs []model.AiChannel
|
||||||
err := s.db.WithContext(ctx).Order("priority ASC, id ASC").Find(&chs).Error
|
if err := s.db.WithContext(ctx).Order("priority ASC, id ASC").Find(&chs).Error; err != nil {
|
||||||
return chs, err
|
return nil, err
|
||||||
|
}
|
||||||
|
var rows []struct {
|
||||||
|
ChannelID uint
|
||||||
|
N int64
|
||||||
|
}
|
||||||
|
q := s.db.WithContext(ctx).Model(&model.AiModelCache{}).
|
||||||
|
Select("channel_id, COUNT(*) AS n").
|
||||||
|
Where("name NOT IN (SELECT name FROM ai_model_blacklists)")
|
||||||
|
if s.FilterDeprecated() {
|
||||||
|
q = q.Where("deprecated_at IS NULL")
|
||||||
|
}
|
||||||
|
err := q.Group("channel_id").Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
counts := make(map[uint]int64, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
counts[r.ChannelID] = r.N
|
||||||
|
}
|
||||||
|
for i := range chs {
|
||||||
|
chs[i].ModelCount = counts[chs[i].ID]
|
||||||
|
}
|
||||||
|
return chs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateChannel 修改渠道名称 / 分组 / 启停 / 优先级 / 权重。
|
// UpdateChannel 修改渠道名称 / 分组 / 启停 / 优先级 / 权重。
|
||||||
@@ -262,7 +456,7 @@ func (s *AiGatewayService) DeleteChannel(ctx context.Context, id uint) error {
|
|||||||
|
|
||||||
// ---- 探测与模型同步 ----
|
// ---- 探测与模型同步 ----
|
||||||
|
|
||||||
// ProbeChannel 探测渠道可用性:服务可见性 → 模型同步 → maxTokens=1 配额试调。
|
// ProbeChannel 探测渠道可用性:服务可见性 → 模型同步 → 极小 max_tokens 配额试调。
|
||||||
func (s *AiGatewayService) ProbeChannel(ctx context.Context, id uint) (*model.AiChannel, error) {
|
func (s *AiGatewayService) ProbeChannel(ctx context.Context, id uint) (*model.AiChannel, error) {
|
||||||
var ch model.AiChannel
|
var ch model.AiChannel
|
||||||
if err := s.db.WithContext(ctx).First(&ch, id).Error; err != nil {
|
if err := s.db.WithContext(ctx).First(&ch, id).Error; err != nil {
|
||||||
@@ -290,12 +484,17 @@ func (s *AiGatewayService) ProbeChannel(ctx context.Context, id uint) (*model.Ai
|
|||||||
return &fresh, nil
|
return &fresh, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// probe 执行探测并返回 (状态, 错误摘要);同时完成模型缓存同步。
|
// probe 执行探测并返回 (状态, 错误摘要);同时完成模型缓存同步(黑名单模型不入库)。
|
||||||
func (s *AiGatewayService) probe(ctx context.Context, cred oci.Credentials, ch *model.AiChannel) (string, string) {
|
func (s *AiGatewayService) probe(ctx context.Context, cred oci.Credentials, ch *model.AiChannel) (string, string) {
|
||||||
models, err := s.client.ListGenAiModels(ctx, cred, ch.Region)
|
models, err := s.client.ListGenAiModels(ctx, cred, ch.Region)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return classifyProbeErr(err), truncateErr(oci.CompactError(err))
|
return classifyProbeErr(err), truncateErr(oci.CompactError(err))
|
||||||
}
|
}
|
||||||
|
models = supportedGatewayModels(models)
|
||||||
|
models, err = s.withoutBlacklisted(ctx, models)
|
||||||
|
if err != nil {
|
||||||
|
return "error", truncateErr(err.Error())
|
||||||
|
}
|
||||||
if len(models) == 0 {
|
if len(models) == 0 {
|
||||||
_ = s.replaceModels(ctx, ch.ID, nil)
|
_ = s.replaceModels(ctx, ch.ID, nil)
|
||||||
return "no_service", "区域无可用模型(GenAI 服务不可用或未开放)"
|
return "no_service", "区域无可用模型(GenAI 服务不可用或未开放)"
|
||||||
@@ -306,40 +505,98 @@ func (s *AiGatewayService) probe(ctx context.Context, cred oci.Credentials, ch *
|
|||||||
return s.probeChat(ctx, cred, ch, models)
|
return s.probeChat(ctx, cred, ch, models)
|
||||||
}
|
}
|
||||||
|
|
||||||
// probeChat 按偏好挑选至多 3 个模型依次试调:部分模型元数据标 CHAT 但实际
|
// probeChat 按候选顺序试调(上限 8,已验证的探测模型置首位):遇「模型不可按需
|
||||||
// 不可对话(如 voice agent),遇 400/5xx 换下一个;401/403/404 属租户级直接定论。
|
// 调用」(微调基座 400 / 实体不存在 404)换下一个候选,错误信息带模型名供用户加入
|
||||||
|
// 黑名单;401/403 与鉴权类 404 可能只是模型级无权限,记录后继续换候选,全部候选
|
||||||
|
// 失败且出现过鉴权拒绝才定论 no_quota;其余错误累计 3 次止损。
|
||||||
func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials, ch *model.AiChannel, models []oci.GenAiModel) (string, string) {
|
func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials, ch *model.AiChannel, models []oci.GenAiModel) (string, string) {
|
||||||
status, detail := "error", "无可试调对话模型"
|
status, detail := "error", "无可试调对话模型"
|
||||||
for _, m := range probeCandidates(models) {
|
quotaDetail := ""
|
||||||
|
errBudget := 3
|
||||||
|
for _, m := range probeCandidates(ch.ProbeModel, models) {
|
||||||
code, err := s.client.GenAiProbeChat(ctx, cred, ch.Region, m.Ocid, m.Name)
|
code, err := s.client.GenAiProbeChat(ctx, cred, ch.Region, m.Ocid, m.Name)
|
||||||
switch {
|
switch {
|
||||||
case code == 200 || code == 429:
|
case code == 200 || code == 429:
|
||||||
return "ok", ""
|
return "ok", ""
|
||||||
|
case oci.IsModelUnavailable(err):
|
||||||
|
status, detail = "error", truncateErr(fmt.Sprintf("%s: 不可按需调用,建议加入模型黑名单", m.Name))
|
||||||
case code == 401 || code == 403 || code == 404:
|
case code == 401 || code == 403 || code == 404:
|
||||||
return "no_quota", truncateErr(oci.CompactError(err))
|
quotaDetail = truncateErr(fmt.Sprintf("%s: %s", m.Name, oci.CompactError(err)))
|
||||||
default:
|
default:
|
||||||
status, detail = "error", truncateErr(fmt.Sprintf("%s: %s", m.Name, oci.CompactError(err)))
|
status, detail = "error", truncateErr(fmt.Sprintf("%s: %s", m.Name, oci.CompactError(err)))
|
||||||
|
if errBudget--; errBudget == 0 {
|
||||||
|
return status, detail
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if quotaDetail != "" {
|
||||||
|
return "no_quota", quotaDetail
|
||||||
|
}
|
||||||
return status, detail
|
return status, detail
|
||||||
}
|
}
|
||||||
|
|
||||||
// probeCandidates 只取对话模型并按可靠度排序取前 3:主流文本模型优先,
|
// probeCandidateCap 是单次探测的候选上限:放宽到 8 让一次探测有机会越过整批
|
||||||
// voice 等非常规形态殿后(embedding / rerank 已被能力筛选排除)。
|
// 不可按需调用的坏模型找到可用者;其他错误另有 3 次止损预算。
|
||||||
func probeCandidates(models []oci.GenAiModel) []oci.GenAiModel {
|
const probeCandidateCap = 8
|
||||||
var sorted []oci.GenAiModel
|
|
||||||
|
// probeCandidates 只取对话模型,按可靠度排序后跨厂商取候选:用户已验证的
|
||||||
|
// probeModel 固定放首位(不做能力过滤,测试通过即有效),其余主流文本模型优先;
|
||||||
|
// voice 等负分形态(元数据标 CHAT 但实际不可对话)直接排除,不浪费试调预算;
|
||||||
|
// 每厂商先取最高分再按分数补位——部分区域某厂商全为微调基座(调用必失败),
|
||||||
|
// 不能让单一厂商占满候选名额拖垮整个渠道的探测结论。
|
||||||
|
func probeCandidates(probeModel string, models []oci.GenAiModel) []oci.GenAiModel {
|
||||||
|
var pinned, sorted []oci.GenAiModel
|
||||||
for _, m := range models {
|
for _, m := range models {
|
||||||
if m.Capability == "" || m.Capability == "CHAT" {
|
if probeModel != "" && m.Name == probeModel {
|
||||||
|
pinned = append(pinned, m)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (m.Capability == "" || m.Capability == "CHAT") && probeScore(m.Name) >= 0 {
|
||||||
sorted = append(sorted, m)
|
sorted = append(sorted, m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.SliceStable(sorted, func(i, j int) bool {
|
sort.SliceStable(sorted, func(i, j int) bool {
|
||||||
return probeScore(sorted[i].Name) > probeScore(sorted[j].Name)
|
return probeScore(sorted[i].Name) > probeScore(sorted[j].Name)
|
||||||
})
|
})
|
||||||
if len(sorted) > 3 {
|
return append(pinned, diversifyByVendor(sorted, probeCandidateCap)...)
|
||||||
sorted = sorted[:3]
|
}
|
||||||
|
|
||||||
|
// diversifyByVendor 从已排序列表先每厂商各取一个,不足 limit 再按原序补位。
|
||||||
|
func diversifyByVendor(sorted []oci.GenAiModel, limit int) []oci.GenAiModel {
|
||||||
|
picked := make([]oci.GenAiModel, 0, limit)
|
||||||
|
used := make(map[int]bool)
|
||||||
|
seenVendor := make(map[string]bool)
|
||||||
|
for i, m := range sorted {
|
||||||
|
if len(picked) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if v := modelVendor(m); !seenVendor[v] {
|
||||||
|
seenVendor[v] = true
|
||||||
|
used[i] = true
|
||||||
|
picked = append(picked, m)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return sorted
|
for i, m := range sorted {
|
||||||
|
if len(picked) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !used[i] {
|
||||||
|
picked = append(picked, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return picked
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelVendor 取厂商标识;OCI 未回填 vendor 时退化为模型名「.」前缀。
|
||||||
|
func modelVendor(m oci.GenAiModel) string {
|
||||||
|
if m.Vendor != "" {
|
||||||
|
return strings.ToLower(m.Vendor)
|
||||||
|
}
|
||||||
|
name := strings.ToLower(m.Name)
|
||||||
|
if i := strings.Index(name, "."); i > 0 {
|
||||||
|
return name[:i]
|
||||||
|
}
|
||||||
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
func probeScore(name string) int {
|
func probeScore(name string) int {
|
||||||
@@ -377,7 +634,7 @@ func truncateErr(msg string) string {
|
|||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncModels 重新拉取渠道区域的模型列表并覆盖缓存。
|
// SyncModels 重新拉取渠道区域的模型列表并覆盖缓存,黑名单中的模型不入库。
|
||||||
func (s *AiGatewayService) SyncModels(ctx context.Context, id uint) ([]model.AiModelCache, error) {
|
func (s *AiGatewayService) SyncModels(ctx context.Context, id uint) ([]model.AiModelCache, error) {
|
||||||
var ch model.AiChannel
|
var ch model.AiChannel
|
||||||
if err := s.db.WithContext(ctx).First(&ch, id).Error; err != nil {
|
if err := s.db.WithContext(ctx).First(&ch, id).Error; err != nil {
|
||||||
@@ -391,6 +648,10 @@ func (s *AiGatewayService) SyncModels(ctx context.Context, id uint) ([]model.AiM
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("同步模型失败:%s", oci.CompactError(err))
|
return nil, fmt.Errorf("同步模型失败:%s", oci.CompactError(err))
|
||||||
}
|
}
|
||||||
|
models = supportedGatewayModels(models)
|
||||||
|
if models, err = s.withoutBlacklisted(ctx, models); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if err := s.replaceModels(ctx, id, models); err != nil {
|
if err := s.replaceModels(ctx, id, models); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -416,13 +677,81 @@ func (s *AiGatewayService) replaceModels(ctx context.Context, channelID uint, mo
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// channelModels 列出渠道模型缓存;黑名单模型查询层兜底过滤
|
||||||
|
// (拉黑即删缓存,正常不会残留,防御旧数据 / 并发窗口);
|
||||||
|
// 「过滤弃用模型」开关开启时同样剔除已宣布弃用者(数据保留,展示口径过滤)。
|
||||||
func (s *AiGatewayService) channelModels(ctx context.Context, channelID uint) ([]model.AiModelCache, error) {
|
func (s *AiGatewayService) channelModels(ctx context.Context, channelID uint) ([]model.AiModelCache, error) {
|
||||||
|
q := s.db.WithContext(ctx).Where("channel_id = ?", channelID).
|
||||||
|
Where("name NOT IN (SELECT name FROM ai_model_blacklists)")
|
||||||
|
if s.FilterDeprecated() {
|
||||||
|
q = q.Where("deprecated_at IS NULL")
|
||||||
|
}
|
||||||
var rows []model.AiModelCache
|
var rows []model.AiModelCache
|
||||||
err := s.db.WithContext(ctx).Where("channel_id = ?", channelID).Order("name ASC").Find(&rows).Error
|
err := q.Order("name ASC").Find(&rows).Error
|
||||||
return rows, err
|
return rows, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GatewayModels 聚合启用渠道的模型(按名称去重),供 /ai/v1/models;
|
// ChannelModels 列出渠道的模型缓存(名称排序),渠道不存在时报错。
|
||||||
|
func (s *AiGatewayService) ChannelModels(ctx context.Context, id uint) ([]model.AiModelCache, error) {
|
||||||
|
var n int64
|
||||||
|
if err := s.db.WithContext(ctx).Model(&model.AiChannel{}).Where("id = ?", id).Count(&n).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return nil, fmt.Errorf("渠道不存在")
|
||||||
|
}
|
||||||
|
return s.channelModels(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChannelModel 对渠道缓存中的指定模型发极小试调;通过时把该模型设
|
||||||
|
// 为渠道探测验证模型(此后探测置于候选首位),渠道探测状态不为 ok 时顺带置 ok 并
|
||||||
|
// 复位熔断;未通过仅返回错误,不改动渠道状态。
|
||||||
|
func (s *AiGatewayService) TestChannelModel(ctx context.Context, id uint, name string) (*model.AiChannel, error) {
|
||||||
|
var ch model.AiChannel
|
||||||
|
if err := s.db.WithContext(ctx).First(&ch, id).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("渠道不存在")
|
||||||
|
}
|
||||||
|
var mc model.AiModelCache
|
||||||
|
err := s.db.WithContext(ctx).Where("channel_id = ? AND name = ?", id, name).First(&mc).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("模型不在该渠道缓存中,请先同步模型")
|
||||||
|
}
|
||||||
|
cred, err := s.configs.credentialsByID(ctx, ch.OciConfigID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
code, err := s.client.GenAiProbeChat(ctx, cred, ch.Region, mc.ModelOcid, mc.Name)
|
||||||
|
if code != 200 && code != 429 {
|
||||||
|
msg := fmt.Sprintf("HTTP %d", code)
|
||||||
|
if err != nil {
|
||||||
|
msg = oci.CompactError(err)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("测试未通过:%s", truncateErr(msg))
|
||||||
|
}
|
||||||
|
return s.adoptProbeModel(ctx, &ch, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adoptProbeModel 记录探测验证模型并返回更新后的渠道;
|
||||||
|
// 状态不为 ok 时一并置 ok 并复位熔断。
|
||||||
|
func (s *AiGatewayService) adoptProbeModel(ctx context.Context, ch *model.AiChannel, name string) (*model.AiChannel, error) {
|
||||||
|
updates := map[string]any{"probe_model": name}
|
||||||
|
if ch.ProbeStatus != "ok" {
|
||||||
|
updates["probe_status"] = "ok"
|
||||||
|
updates["probe_error"] = ""
|
||||||
|
updates["last_probe_at"] = time.Now()
|
||||||
|
updates["fail_count"] = 0
|
||||||
|
updates["disabled_until"] = gorm.Expr("NULL")
|
||||||
|
}
|
||||||
|
if err := s.db.WithContext(ctx).Model(&model.AiChannel{}).Where("id = ?", ch.ID).Updates(updates).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 重读用新变量:gorm 扫描 NULL 列到已有值的结构体时会保留旧值
|
||||||
|
var fresh model.AiChannel
|
||||||
|
err := s.db.WithContext(ctx).First(&fresh, ch.ID).Error
|
||||||
|
return &fresh, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GatewayModels 聚合启用渠道的可用模型(按名称去重),供 /ai/v1/models;
|
||||||
// group 非空时仅聚合该分组渠道(与密钥分组路由口径一致)。
|
// group 非空时仅聚合该分组渠道(与密钥分组路由口径一致)。
|
||||||
func (s *AiGatewayService) GatewayModels(ctx context.Context, group string) (aiwire.ModelList, error) {
|
func (s *AiGatewayService) GatewayModels(ctx context.Context, group string) (aiwire.ModelList, error) {
|
||||||
q := s.db.WithContext(ctx).
|
q := s.db.WithContext(ctx).
|
||||||
@@ -430,6 +759,9 @@ func (s *AiGatewayService) GatewayModels(ctx context.Context, group string) (aiw
|
|||||||
if group != "" {
|
if group != "" {
|
||||||
q = q.Where("ai_channels.channel_group = ?", group)
|
q = q.Where("ai_channels.channel_group = ?", group)
|
||||||
}
|
}
|
||||||
|
if s.FilterDeprecated() {
|
||||||
|
q = q.Where("ai_model_caches.deprecated_at IS NULL")
|
||||||
|
}
|
||||||
var rows []model.AiModelCache
|
var rows []model.AiModelCache
|
||||||
err := q.Order("ai_model_caches.name ASC").Find(&rows).Error
|
err := q.Order("ai_model_caches.name ASC").Find(&rows).Error
|
||||||
list := aiwire.ModelList{Object: "list", Data: []aiwire.Model{}}
|
list := aiwire.ModelList{Object: "list", Data: []aiwire.Model{}}
|
||||||
@@ -447,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)才导致不可调用,单独标注;已过弃用日但未到退役日的
|
||||||
// 模型仍可正常调用,不再反复告警;已过退役日的在同步层剔除,不会出现在池中。
|
// 模型仍可正常调用,不再反复告警;已过退役日的在同步层剔除,不会出现在池中。
|
||||||
@@ -502,6 +868,99 @@ func (s *AiGatewayService) ProbeAll(ctx context.Context) (string, error) {
|
|||||||
return msg, nil
|
return msg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 模型黑名单 ----
|
||||||
|
|
||||||
|
// Blacklist 列出全部黑名单模型(按名称排序)。
|
||||||
|
func (s *AiGatewayService) Blacklist(ctx context.Context) ([]model.AiModelBlacklist, error) {
|
||||||
|
var rows []model.AiModelBlacklist
|
||||||
|
err := s.db.WithContext(ctx).Order("name ASC").Find(&rows).Error
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddBlacklist 把模型名加入黑名单并删除全部渠道缓存中的同名条目;
|
||||||
|
// 该模型此后同步 / 探测均被过滤,直到移出黑名单后重新同步。
|
||||||
|
func (s *AiGatewayService) AddBlacklist(ctx context.Context, name string) (*model.AiModelBlacklist, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("模型名不能为空")
|
||||||
|
}
|
||||||
|
row := model.AiModelBlacklist{Name: name}
|
||||||
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var n int64
|
||||||
|
if err := tx.Model(&model.AiModelBlacklist{}).Where("name = ?", name).Count(&n).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
return fmt.Errorf("模型已在黑名单中")
|
||||||
|
}
|
||||||
|
if err := tx.Create(&row).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Where("name = ?", name).Delete(&model.AiModelCache{}).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveBlacklist 把模型移出黑名单;缓存不回填,下次同步 / 探测自然恢复入池。
|
||||||
|
func (s *AiGatewayService) RemoveBlacklist(ctx context.Context, id uint) error {
|
||||||
|
res := s.db.WithContext(ctx).Delete(&model.AiModelBlacklist{}, id)
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return fmt.Errorf("黑名单条目不存在")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// withoutBlacklisted 过滤掉黑名单中的模型,同步与探测入库前统一经此收口。
|
||||||
|
// supportedGatewayModels 过滤模型目录:对话模型仅保留实测支持 OpenAI 兼容面的
|
||||||
|
// 厂商(xai / meta / openai)——typed chat 面已剔除,google / cohere 对话模型无
|
||||||
|
// 上游通路,不入目录(不出现在列表、路由与探测候选);EMBEDDING 模型不受影响。
|
||||||
|
func supportedGatewayModels(models []oci.GenAiModel) []oci.GenAiModel {
|
||||||
|
out := make([]oci.GenAiModel, 0, len(models))
|
||||||
|
for _, m := range models {
|
||||||
|
if m.Capability == "CHAT" && !compatChatVendor(m.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func compatChatVendor(model string) bool {
|
||||||
|
for _, prefix := range []string{"xai.", "meta.", "openai."} {
|
||||||
|
if strings.HasPrefix(model, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AiGatewayService) withoutBlacklisted(ctx context.Context, models []oci.GenAiModel) ([]oci.GenAiModel, error) {
|
||||||
|
var names []string
|
||||||
|
if err := s.db.WithContext(ctx).Model(&model.AiModelBlacklist{}).Pluck("name", &names).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("读取模型黑名单: %w", err)
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
return models, nil
|
||||||
|
}
|
||||||
|
banned := make(map[string]bool, len(names))
|
||||||
|
for _, n := range names {
|
||||||
|
banned[n] = true
|
||||||
|
}
|
||||||
|
out := make([]oci.GenAiModel, 0, len(models))
|
||||||
|
for _, m := range models {
|
||||||
|
if !banned[m.Name] {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 调用日志 ----
|
// ---- 调用日志 ----
|
||||||
|
|
||||||
// LogCall 落一条调用日志(仅元数据与用量,永不含请求 / 响应正文),返回落库 ID 供内容日志关联(失败为 0)。
|
// LogCall 落一条调用日志(仅元数据与用量,永不含请求 / 响应正文),返回落库 ID 供内容日志关联(失败为 0)。
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -26,72 +27,64 @@ type aiCandidate struct {
|
|||||||
modelOcid string
|
modelOcid string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chat 编排非流式调用:选渠道 → 调用 → 可重试错误换渠道(整请求上限 3 次)。
|
// routeRetry 统一编排「选渠道(priority→加权随机)→ 调用 → 可重试错误换渠道」,
|
||||||
// group 非空时只在同分组渠道内路由(取自调用密钥)。
|
// 整请求上限 3 次并维护熔断;group 非空时只在同分组渠道内路由。
|
||||||
func (s *AiGatewayService) Chat(ctx context.Context, ir aiwire.ChatRequest, group string) (*aiwire.ChatResponse, ChatMeta, error) {
|
func routeRetry[T any](ctx context.Context, s *AiGatewayService, modelName, group, capability string, once func(*aiCandidate) (T, error)) (T, ChatMeta, error) {
|
||||||
|
var zero T
|
||||||
meta := ChatMeta{}
|
meta := ChatMeta{}
|
||||||
excluded := map[uint]bool{}
|
excluded := map[uint]bool{}
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 0; attempt < 3; attempt++ {
|
for attempt := 0; attempt < 3; attempt++ {
|
||||||
cand, err := s.pick(ctx, ir.Model, group, "CHAT", excluded)
|
cand, err := s.pick(ctx, modelName, group, capability, excluded)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, meta, firstErr(lastErr, err)
|
return zero, meta, firstErr(lastErr, err)
|
||||||
}
|
}
|
||||||
meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name
|
meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name
|
||||||
resp, err := s.callOnce(ctx, cand, ir)
|
out, err := once(cand)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
s.markSuccess(ctx, cand.ch.ID)
|
s.markSuccess(ctx, cand.ch.ID)
|
||||||
return resp, meta, nil
|
return out, meta, nil
|
||||||
}
|
}
|
||||||
if !retryable(err) {
|
retry, penalize := switchable(err)
|
||||||
return nil, meta, err
|
if !retry {
|
||||||
|
return zero, meta, err
|
||||||
|
}
|
||||||
|
if penalize {
|
||||||
|
s.markFailure(ctx, cand.ch.ID)
|
||||||
}
|
}
|
||||||
s.markFailure(ctx, cand.ch.ID)
|
|
||||||
excluded[cand.ch.ID] = true
|
excluded[cand.ch.ID] = true
|
||||||
meta.Retries++
|
meta.Retries++
|
||||||
lastErr = err
|
lastErr = err
|
||||||
}
|
}
|
||||||
return nil, meta, lastErr
|
return zero, meta, lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AiGatewayService) callOnce(ctx context.Context, cand *aiCandidate, ir aiwire.ChatRequest) (*aiwire.ChatResponse, error) {
|
// RespPassthrough 编排一次非流式直通调用。
|
||||||
|
// 上游为 OpenAI-compatible /actions/v1/responses(实测可用,无 Oracle 文档合同)。
|
||||||
|
func (s *AiGatewayService) RespPassthrough(ctx context.Context, raw []byte, modelName, group string) ([]byte, ChatMeta, error) {
|
||||||
|
return routeRetry(ctx, s, modelName, group, "CHAT", func(cand *aiCandidate) ([]byte, error) {
|
||||||
|
return s.passthroughOnce(ctx, cand, raw)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AiGatewayService) passthroughOnce(ctx context.Context, cand *aiCandidate, raw []byte) ([]byte, error) {
|
||||||
cred, err := s.configs.credentialsByID(ctx, cand.ch.OciConfigID)
|
cred, err := s.configs.credentialsByID(ctx, cand.ch.OciConfigID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.client.GenAiChat(ctx, cred, cand.ch.Region, cand.modelOcid, ir)
|
return s.client.GenAiCompatResponses(ctx, cred, cand.ch.Region, raw, s.UpstreamWait())
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenStream 编排流式调用:流建立成功后即绑定渠道,建立失败可换渠道重试。
|
// RespPassthroughStream 编排流式直通:流建立成功即绑定渠道,建立失败按 switchable
|
||||||
// group 语义与 Chat 相同。
|
// 换渠道重试;建立后的中断不重试、不计熔断(与 OpenStream 语义一致)。
|
||||||
func (s *AiGatewayService) OpenStream(ctx context.Context, ir aiwire.ChatRequest, group string) (oci.GenAiStream, ChatMeta, error) {
|
func (s *AiGatewayService) RespPassthroughStream(ctx context.Context, raw []byte, modelName, group string) (io.ReadCloser, ChatMeta, error) {
|
||||||
meta := ChatMeta{}
|
return routeRetry(ctx, s, modelName, group, "CHAT", func(cand *aiCandidate) (io.ReadCloser, error) {
|
||||||
excluded := map[uint]bool{}
|
|
||||||
var lastErr error
|
|
||||||
for attempt := 0; attempt < 3; attempt++ {
|
|
||||||
cand, err := s.pick(ctx, ir.Model, group, "CHAT", excluded)
|
|
||||||
if err != nil {
|
|
||||||
return nil, meta, firstErr(lastErr, err)
|
|
||||||
}
|
|
||||||
meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name
|
|
||||||
cred, err := s.configs.credentialsByID(ctx, cand.ch.OciConfigID)
|
cred, err := s.configs.credentialsByID(ctx, cand.ch.OciConfigID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, meta, err
|
return nil, err
|
||||||
}
|
}
|
||||||
stream, err := s.client.GenAiChatStream(ctx, cred, cand.ch.Region, cand.modelOcid, ir)
|
return s.client.GenAiCompatResponsesStream(ctx, cred, cand.ch.Region, raw, s.UpstreamWait())
|
||||||
if err == nil {
|
})
|
||||||
s.markSuccess(ctx, cand.ch.ID)
|
|
||||||
return stream, meta, nil
|
|
||||||
}
|
|
||||||
if !retryable(err) {
|
|
||||||
return nil, meta, err
|
|
||||||
}
|
|
||||||
s.markFailure(ctx, cand.ch.ID)
|
|
||||||
excluded[cand.ch.ID] = true
|
|
||||||
meta.Retries++
|
|
||||||
lastErr = err
|
|
||||||
}
|
|
||||||
return nil, meta, lastErr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// firstErr 在换渠道后仍失败时优先返回上游错误(而非「无渠道」)。
|
// firstErr 在换渠道后仍失败时优先返回上游错误(而非「无渠道」)。
|
||||||
@@ -102,12 +95,17 @@ func firstErr(lastErr, pickErr error) error {
|
|||||||
return pickErr
|
return pickErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// retryable 判定是否换渠道重试:429 / 5xx / 网络错误可重试,其余 4xx 直接透传。
|
// switchable 判断调用失败是否换渠道重试、是否计入熔断:模型级不可用(微调基座
|
||||||
func retryable(err error) bool {
|
// 400 / 实体不存在 404)换渠道但不计熔断——这是模型×区域供给问题而非渠道健康
|
||||||
if status, ok := oci.ServiceStatus(err); ok {
|
// 问题,持久解决靠加入模型黑名单;429 / 5xx / 网络错误换渠道并计熔断;其余 4xx 直接透传。
|
||||||
return status == 429 || status >= 500
|
func switchable(err error) (retry, penalize bool) {
|
||||||
|
if oci.IsModelUnavailable(err) {
|
||||||
|
return true, false
|
||||||
}
|
}
|
||||||
return true
|
if status, ok := oci.ServiceStatus(err); ok {
|
||||||
|
return status == 429 || status >= 500, true
|
||||||
|
}
|
||||||
|
return true, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// pick 选出支持该模型的最优渠道:能力匹配 → 启用 → 分组匹配 → 未熔断 → 最小优先级组 → 加权随机。
|
// pick 选出支持该模型的最优渠道:能力匹配 → 启用 → 分组匹配 → 未熔断 → 最小优先级组 → 加权随机。
|
||||||
@@ -151,6 +149,9 @@ func (s *AiGatewayService) modelChannels(ctx context.Context, modelName, capabil
|
|||||||
} else {
|
} else {
|
||||||
q = q.Where("capability = ?", capability)
|
q = q.Where("capability = ?", capability)
|
||||||
}
|
}
|
||||||
|
if s.FilterDeprecated() {
|
||||||
|
q = q.Where("deprecated_at IS NULL")
|
||||||
|
}
|
||||||
var rows []model.AiModelCache
|
var rows []model.AiModelCache
|
||||||
if err := q.Find(&rows).Error; err != nil {
|
if err := q.Find(&rows).Error; err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -205,31 +206,11 @@ func weightedPick(chs []model.AiChannel) model.AiChannel {
|
|||||||
return chs[len(chs)-1]
|
return chs[len(chs)-1]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embeddings 编排向量化调用:按 EMBEDDING 能力选渠道,可重试错误换渠道(整请求上限 3 次)。
|
// Embeddings 编排向量化调用:按 EMBEDDING 能力选渠道,可重试错误换渠道。
|
||||||
func (s *AiGatewayService) Embeddings(ctx context.Context, req aiwire.EmbeddingsRequest, group string) (*aiwire.EmbeddingsResponse, ChatMeta, error) {
|
func (s *AiGatewayService) Embeddings(ctx context.Context, req aiwire.EmbeddingsRequest, group string) (*aiwire.EmbeddingsResponse, ChatMeta, error) {
|
||||||
meta := ChatMeta{}
|
return routeRetry(ctx, s, req.Model, group, "EMBEDDING", func(cand *aiCandidate) (*aiwire.EmbeddingsResponse, error) {
|
||||||
excluded := map[uint]bool{}
|
return s.embedOnce(ctx, cand, req)
|
||||||
var lastErr error
|
})
|
||||||
for attempt := 0; attempt < 3; attempt++ {
|
|
||||||
cand, err := s.pick(ctx, req.Model, group, "EMBEDDING", excluded)
|
|
||||||
if err != nil {
|
|
||||||
return nil, meta, firstErr(lastErr, err)
|
|
||||||
}
|
|
||||||
meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name
|
|
||||||
resp, err := s.embedOnce(ctx, cand, req)
|
|
||||||
if err == nil {
|
|
||||||
s.markSuccess(ctx, cand.ch.ID)
|
|
||||||
return resp, meta, nil
|
|
||||||
}
|
|
||||||
if !retryable(err) {
|
|
||||||
return nil, meta, err
|
|
||||||
}
|
|
||||||
s.markFailure(ctx, cand.ch.ID)
|
|
||||||
excluded[cand.ch.ID] = true
|
|
||||||
meta.Retries++
|
|
||||||
lastErr = err
|
|
||||||
}
|
|
||||||
return nil, meta, lastErr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// embedOnce 调用渠道向量化并装配 OpenAI 形态响应。
|
// embedOnce 调用渠道向量化并装配 OpenAI 形态响应。
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"oci-portal/internal/aiwire"
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
)
|
||||||
|
|
||||||
|
// moderationsMaxInputs 限制单次审核条数,防止批量滥用拖垮渠道配额。
|
||||||
|
const moderationsMaxInputs = 8
|
||||||
|
|
||||||
|
// SpeechBodyNormalize 校验并规范化 TTS 请求体:model / input 必填;
|
||||||
|
// xAI 上游把 language 当必填(实测缺失返回 422),缺省注入 "auto"。
|
||||||
|
// 其余字段(voice / response_format / extra_body 平铺项)原样保留。
|
||||||
|
func SpeechBodyNormalize(raw []byte) (string, []byte, error) {
|
||||||
|
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||||
|
dec.UseNumber()
|
||||||
|
var body map[string]any
|
||||||
|
if err := dec.Decode(&body); err != nil {
|
||||||
|
return "", nil, fmt.Errorf("解析请求体: %w", err)
|
||||||
|
}
|
||||||
|
modelName, _ := body["model"].(string)
|
||||||
|
input, _ := body["input"].(string)
|
||||||
|
if strings.TrimSpace(modelName) == "" || strings.TrimSpace(input) == "" {
|
||||||
|
return "", nil, fmt.Errorf("model 与 input 不能为空")
|
||||||
|
}
|
||||||
|
if lang, ok := body["language"].(string); !ok || strings.TrimSpace(lang) == "" {
|
||||||
|
body["language"] = "auto"
|
||||||
|
}
|
||||||
|
out, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("重组请求体: %w", err)
|
||||||
|
}
|
||||||
|
return modelName, out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ttsDefaultModel 是 /ai/v1/tts 缺省注入的模型(xAI 官方格式无 model 字段)。
|
||||||
|
const ttsDefaultModel = "xai.grok-tts"
|
||||||
|
|
||||||
|
// TtsBodyConvert 把 xAI 官方 TTS 格式转换为上游 OpenAI 兼容 audio/speech 形态:
|
||||||
|
// text→input、voice_id→voice,text 与 language 必填(对齐 xAI 官方);
|
||||||
|
// model 为网关扩展字段,缺省注入 ttsDefaultModel;其余字段原样保留。
|
||||||
|
func TtsBodyConvert(raw []byte) (string, []byte, error) {
|
||||||
|
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||||
|
dec.UseNumber()
|
||||||
|
var body map[string]any
|
||||||
|
if err := dec.Decode(&body); err != nil {
|
||||||
|
return "", nil, fmt.Errorf("解析请求体: %w", err)
|
||||||
|
}
|
||||||
|
text, _ := body["text"].(string)
|
||||||
|
language, _ := body["language"].(string)
|
||||||
|
if strings.TrimSpace(text) == "" || strings.TrimSpace(language) == "" {
|
||||||
|
return "", nil, fmt.Errorf("text 与 language 不能为空")
|
||||||
|
}
|
||||||
|
modelName, _ := body["model"].(string)
|
||||||
|
if strings.TrimSpace(modelName) == "" {
|
||||||
|
modelName = ttsDefaultModel
|
||||||
|
body["model"] = modelName
|
||||||
|
}
|
||||||
|
delete(body, "text")
|
||||||
|
body["input"] = text
|
||||||
|
if voice, ok := body["voice_id"].(string); ok && strings.TrimSpace(voice) != "" {
|
||||||
|
body["voice"] = voice
|
||||||
|
}
|
||||||
|
delete(body, "voice_id")
|
||||||
|
out, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("重组请求体: %w", err)
|
||||||
|
}
|
||||||
|
return modelName, out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speech 编排 TTS 调用:按 TTS 能力选渠道,可重试错误换渠道(上限 3 次)。
|
||||||
|
func (s *AiGatewayService) Speech(ctx context.Context, modelName string, body []byte, group string) ([]byte, string, ChatMeta, error) {
|
||||||
|
meta := ChatMeta{}
|
||||||
|
excluded := map[uint]bool{}
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < 3; attempt++ {
|
||||||
|
cand, err := s.pick(ctx, modelName, group, "TTS", excluded)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", meta, firstErr(lastErr, err)
|
||||||
|
}
|
||||||
|
meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name
|
||||||
|
cred, err := s.configs.credentialsByID(ctx, cand.ch.OciConfigID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", meta, err
|
||||||
|
}
|
||||||
|
audio, contentType, err := s.client.GenAiCompatSpeech(ctx, cred, cand.ch.Region, body)
|
||||||
|
if err == nil {
|
||||||
|
s.markSuccess(ctx, cand.ch.ID)
|
||||||
|
return audio, contentType, meta, nil
|
||||||
|
}
|
||||||
|
if done := s.retryStep(ctx, cand.ch.ID, err, excluded, &meta, &lastErr); done {
|
||||||
|
return nil, "", meta, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, "", meta, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rerank 编排文档重排:按 RERANK 能力选渠道,装配 Jina 风格响应。
|
||||||
|
func (s *AiGatewayService) Rerank(ctx context.Context, req aiwire.RerankRequest, group string) (*aiwire.RerankResponse, ChatMeta, error) {
|
||||||
|
meta := ChatMeta{}
|
||||||
|
excluded := map[uint]bool{}
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < 3; attempt++ {
|
||||||
|
cand, err := s.pick(ctx, req.Model, group, "RERANK", excluded)
|
||||||
|
if err != nil {
|
||||||
|
return nil, meta, firstErr(lastErr, err)
|
||||||
|
}
|
||||||
|
meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name
|
||||||
|
cred, err := s.configs.credentialsByID(ctx, cand.ch.OciConfigID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, meta, err
|
||||||
|
}
|
||||||
|
ranks, err := s.client.GenAiRerank(ctx, cred, cand.ch.Region, cand.modelOcid, req.Query, req.Documents, req.TopN)
|
||||||
|
if err == nil {
|
||||||
|
s.markSuccess(ctx, cand.ch.ID)
|
||||||
|
return rerankResponse(req, ranks), meta, nil
|
||||||
|
}
|
||||||
|
if done := s.retryStep(ctx, cand.ch.ID, err, excluded, &meta, &lastErr); done {
|
||||||
|
return nil, meta, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, meta, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// retryStep 统一处理换渠道重试:不可重试返回 true 终止,可重试记罚并排除渠道。
|
||||||
|
func (s *AiGatewayService) retryStep(ctx context.Context, chID uint, err error, excluded map[uint]bool, meta *ChatMeta, lastErr *error) bool {
|
||||||
|
retry, penalize := switchable(err)
|
||||||
|
if !retry {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if penalize {
|
||||||
|
s.markFailure(ctx, chID)
|
||||||
|
}
|
||||||
|
excluded[chID] = true
|
||||||
|
meta.Retries++
|
||||||
|
*lastErr = err
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// rerankResponse 把上游排序结果装配为对外响应;return_documents 时回填原文。
|
||||||
|
func rerankResponse(req aiwire.RerankRequest, ranks []oci.RerankRank) *aiwire.RerankResponse {
|
||||||
|
out := &aiwire.RerankResponse{Model: req.Model, Results: make([]aiwire.RerankResult, 0, len(ranks))}
|
||||||
|
withDoc := req.ReturnDocuments != nil && *req.ReturnDocuments
|
||||||
|
for _, r := range ranks {
|
||||||
|
if r.Index < 0 || r.Index >= len(req.Documents) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item := aiwire.RerankResult{Index: r.Index, RelevanceScore: r.Score}
|
||||||
|
if withDoc {
|
||||||
|
item.Document = &aiwire.RerankDocument{Text: req.Documents[r.Index]}
|
||||||
|
}
|
||||||
|
out.Results = append(out.Results, item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModerationInputs 解析 moderations 的 input 字段:string 或 []string,
|
||||||
|
// 条数与空值校验(上限 moderationsMaxInputs)。
|
||||||
|
func ModerationInputs(raw json.RawMessage) ([]string, error) {
|
||||||
|
var single string
|
||||||
|
if err := json.Unmarshal(raw, &single); err == nil {
|
||||||
|
if strings.TrimSpace(single) == "" {
|
||||||
|
return nil, fmt.Errorf("input 不能为空")
|
||||||
|
}
|
||||||
|
return []string{single}, nil
|
||||||
|
}
|
||||||
|
var many []string
|
||||||
|
if err := json.Unmarshal(raw, &many); err != nil {
|
||||||
|
return nil, fmt.Errorf("input 需为字符串或字符串数组")
|
||||||
|
}
|
||||||
|
if len(many) == 0 || len(many) > moderationsMaxInputs {
|
||||||
|
return nil, fmt.Errorf("input 条数需在 1~%d 之间", moderationsMaxInputs)
|
||||||
|
}
|
||||||
|
for _, item := range many {
|
||||||
|
if strings.TrimSpace(item) == "" {
|
||||||
|
return nil, fmt.Errorf("input 含空条目")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return many, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moderations 编排内容审核:guardrails 为服务级 API 无模型维度,
|
||||||
|
// 按分组选启用渠道(整批同渠道),可重试错误换渠道(上限 3 次)。
|
||||||
|
func (s *AiGatewayService) Moderations(ctx context.Context, id string, inputs []string, group string) (*aiwire.ModerationsResponse, ChatMeta, error) {
|
||||||
|
meta := ChatMeta{}
|
||||||
|
excluded := map[uint]bool{}
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < 3; attempt++ {
|
||||||
|
ch, err := s.pickGuardChannel(ctx, group, excluded)
|
||||||
|
if err != nil {
|
||||||
|
return nil, meta, firstErr(lastErr, err)
|
||||||
|
}
|
||||||
|
meta.ChannelID, meta.ChannelName = ch.ID, ch.Name
|
||||||
|
resp, err := s.moderateOnce(ctx, ch, id, inputs)
|
||||||
|
if err == nil {
|
||||||
|
s.markSuccess(ctx, ch.ID)
|
||||||
|
return resp, meta, nil
|
||||||
|
}
|
||||||
|
if done := s.retryStep(ctx, ch.ID, err, excluded, &meta, &lastErr); done {
|
||||||
|
return nil, meta, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, meta, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickGuardChannel 按分组挑一个启用且未熔断的渠道(权重加成,不查模型缓存)。
|
||||||
|
func (s *AiGatewayService) pickGuardChannel(ctx context.Context, group string, excluded map[uint]bool) (model.AiChannel, error) {
|
||||||
|
q := s.db.WithContext(ctx).Where("enabled = ?", true)
|
||||||
|
if group != "" {
|
||||||
|
q = q.Where("channel_group = ?", group)
|
||||||
|
}
|
||||||
|
var channels []model.AiChannel
|
||||||
|
if err := q.Find(&channels).Error; err != nil {
|
||||||
|
return model.AiChannel{}, err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
avail := channels[:0]
|
||||||
|
for _, ch := range channels {
|
||||||
|
if excluded[ch.ID] || (ch.DisabledUntil != nil && ch.DisabledUntil.After(now)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
avail = append(avail, ch)
|
||||||
|
}
|
||||||
|
if len(avail) == 0 {
|
||||||
|
return model.AiChannel{}, ErrAiNoChannel
|
||||||
|
}
|
||||||
|
return weightedPick(topPriority(avail)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// moderateOnce 在单渠道上逐条审核并装配 OpenAI moderations 外壳。
|
||||||
|
func (s *AiGatewayService) moderateOnce(ctx context.Context, ch model.AiChannel, id string, inputs []string) (*aiwire.ModerationsResponse, error) {
|
||||||
|
cred, err := s.configs.credentialsByID(ctx, ch.OciConfigID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := &aiwire.ModerationsResponse{ID: id, Model: "oci-guardrails",
|
||||||
|
Results: make([]aiwire.ModerationResult, 0, len(inputs))}
|
||||||
|
for _, text := range inputs {
|
||||||
|
outcome, err := s.client.GenAiApplyGuardrails(ctx, cred, ch.Region, text)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out.Results = append(out.Results, moderationResult(outcome))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// moderationFlagThreshold 是判定违规的得分阈值(上游 CM / PI 为二值得分)。
|
||||||
|
const moderationFlagThreshold = 0.5
|
||||||
|
|
||||||
|
// moderationResult 把 guardrails 结果映射为 OpenAI moderations 条目:
|
||||||
|
// flagged 由内容审核与提示注入判定,PII 命中只作扩展信息不参与 flagged。
|
||||||
|
func moderationResult(outcome *oci.GuardrailsOutcome) aiwire.ModerationResult {
|
||||||
|
res := aiwire.ModerationResult{Categories: map[string]bool{}, CategoryScores: map[string]float64{}}
|
||||||
|
for _, cat := range outcome.Categories {
|
||||||
|
key := strings.ToLower(cat.Name)
|
||||||
|
res.Categories[key] = cat.Score >= moderationFlagThreshold
|
||||||
|
res.CategoryScores[key] = cat.Score
|
||||||
|
if res.Categories[key] {
|
||||||
|
res.Flagged = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if outcome.PromptInjectionScore != nil {
|
||||||
|
hit := *outcome.PromptInjectionScore >= moderationFlagThreshold
|
||||||
|
res.Categories["prompt_injection"] = hit
|
||||||
|
res.CategoryScores["prompt_injection"] = *outcome.PromptInjectionScore
|
||||||
|
if hit {
|
||||||
|
res.Flagged = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range outcome.Pii {
|
||||||
|
res.Pii = append(res.Pii, aiwire.ModerationPii{Text: p.Text, Label: p.Label,
|
||||||
|
Score: p.Score, Offset: p.Offset, Length: p.Length})
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"oci-portal/internal/aiwire"
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSpeechBodyNormalize 断言 TTS 请求体校验与 language 缺省注入。
|
||||||
|
func TestSpeechBodyNormalize(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
wantErr bool
|
||||||
|
wantLang string
|
||||||
|
}{
|
||||||
|
{"缺 model 拒绝", `{"input":"你好"}`, true, ""},
|
||||||
|
{"缺 input 拒绝", `{"model":"xai.grok-tts"}`, true, ""},
|
||||||
|
{"language 缺省注入 auto", `{"model":"xai.grok-tts","input":"你好","voice":"ara"}`, false, "auto"},
|
||||||
|
{"language 已有保留", `{"model":"xai.grok-tts","input":"你好","language":"zh"}`, false, "zh"},
|
||||||
|
{"非 JSON 拒绝", `<html>`, true, ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
modelName, body, err := SpeechBodyNormalize([]byte(tt.raw))
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("err = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var out map[string]any
|
||||||
|
_ = json.Unmarshal(body, &out)
|
||||||
|
if modelName != "xai.grok-tts" || out["language"] != tt.wantLang {
|
||||||
|
t.Fatalf("model=%s language=%v, want %s", modelName, out["language"], tt.wantLang)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTtsBodyConvert 断言 xAI 官方 TTS 格式到 OpenAI 兼容形态的转换。
|
||||||
|
func TestTtsBodyConvert(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
wantErr bool
|
||||||
|
wantModel string
|
||||||
|
}{
|
||||||
|
{"缺 text 拒绝", `{"language":"zh"}`, true, ""},
|
||||||
|
{"缺 language 拒绝", `{"text":"你好"}`, true, ""},
|
||||||
|
{"缺省注入默认模型", `{"text":"你好","language":"zh"}`, false, "xai.grok-tts"},
|
||||||
|
{"model 扩展字段可覆盖", `{"model":"xai.other-tts","text":"你好","language":"auto"}`, false, "xai.other-tts"},
|
||||||
|
{"非 JSON 拒绝", `<html>`, true, ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
modelName, _, err := TtsBodyConvert([]byte(tt.raw))
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("err = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if modelName != tt.wantModel {
|
||||||
|
t.Fatalf("model = %s, want %s", modelName, tt.wantModel)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTtsBodyConvertMapping 断言字段映射与未知字段保留。
|
||||||
|
func TestTtsBodyConvertMapping(t *testing.T) {
|
||||||
|
raw := `{"text":"你好","language":"zh","voice_id":"ara","speed":1.2,` +
|
||||||
|
`"output_format":{"codec":"mp3","sample_rate":44100}}`
|
||||||
|
_, body, err := TtsBodyConvert([]byte(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err = %v", err)
|
||||||
|
}
|
||||||
|
var out map[string]any
|
||||||
|
_ = json.Unmarshal(body, &out)
|
||||||
|
if out["input"] != "你好" || out["voice"] != "ara" {
|
||||||
|
t.Fatalf("input/voice 映射错误: %v", out)
|
||||||
|
}
|
||||||
|
if _, ok := out["text"]; ok {
|
||||||
|
t.Fatal("text 字段应被移除")
|
||||||
|
}
|
||||||
|
if _, ok := out["voice_id"]; ok {
|
||||||
|
t.Fatal("voice_id 字段应被移除")
|
||||||
|
}
|
||||||
|
of, _ := out["output_format"].(map[string]any)
|
||||||
|
if of == nil || of["codec"] != "mp3" {
|
||||||
|
t.Fatalf("output_format 应原样保留: %v", out["output_format"])
|
||||||
|
}
|
||||||
|
if out["language"] != "zh" || out["speed"] == nil {
|
||||||
|
t.Fatalf("language/speed 应保留: %v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestModerationInputs 断言 input 的 string / []string 解析与边界校验。
|
||||||
|
func TestModerationInputs(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
wantN int
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"单字符串", `"hello"`, 1, false},
|
||||||
|
{"数组", `["a","b"]`, 2, false},
|
||||||
|
{"空字符串拒绝", `""`, 0, true},
|
||||||
|
{"空数组拒绝", `[]`, 0, true},
|
||||||
|
{"含空条目拒绝", `["a",""]`, 0, true},
|
||||||
|
{"超上限拒绝", `["1","2","3","4","5","6","7","8","9"]`, 0, true},
|
||||||
|
{"非法类型拒绝", `{"x":1}`, 0, true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ModerationInputs(json.RawMessage(tt.raw))
|
||||||
|
if (err != nil) != tt.wantErr || len(got) != tt.wantN {
|
||||||
|
t.Fatalf("got %v (err=%v), want n=%d wantErr=%v", got, err, tt.wantN, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestModerationResultMapping 断言 guardrails 结果到 OpenAI 外壳的映射与 flagged 判定。
|
||||||
|
func TestModerationResultMapping(t *testing.T) {
|
||||||
|
one := 1.0
|
||||||
|
zero := 0.0
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
outcome oci.GuardrailsOutcome
|
||||||
|
wantFlagged bool
|
||||||
|
wantPii int
|
||||||
|
}{
|
||||||
|
{"内容审核命中", oci.GuardrailsOutcome{Categories: []oci.GuardrailCategory{{Name: "OVERALL", Score: 1}}}, true, 0},
|
||||||
|
{"提示注入命中", oci.GuardrailsOutcome{PromptInjectionScore: &one}, true, 0},
|
||||||
|
{"仅 PII 不 flag", oci.GuardrailsOutcome{Categories: []oci.GuardrailCategory{{Name: "OVERALL", Score: 0}},
|
||||||
|
PromptInjectionScore: &zero, Pii: []oci.GuardrailPiiHit{{Text: "Jane", Label: "PERSON", Score: 0.99}}}, false, 1},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
res := moderationResult(&tt.outcome)
|
||||||
|
if res.Flagged != tt.wantFlagged || len(res.Pii) != tt.wantPii {
|
||||||
|
t.Fatalf("res = %+v, want flagged=%v pii=%d", res, tt.wantFlagged, tt.wantPii)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAiRerank 断言重排编排:能力路由、top_n 透传、return_documents 回填与越界防御。
|
||||||
|
func TestAiRerank(t *testing.T) {
|
||||||
|
client := &gatewayStubClient{
|
||||||
|
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||||
|
rerankRanks: []oci.RerankRank{{Index: 1, Score: 0.9}, {Index: 0, Score: 0.4}, {Index: 9, Score: 0.1}},
|
||||||
|
}
|
||||||
|
gw, svc := newTestGateway(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
ch := seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
|
||||||
|
gw.db.Create(&model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..rr", Name: "cohere.rerank-v4.0-fast",
|
||||||
|
Vendor: "cohere", Capability: "RERANK", SyncedAt: time.Now()})
|
||||||
|
ctx := context.Background()
|
||||||
|
yes := true
|
||||||
|
req := aiwire.RerankRequest{Model: "cohere.rerank-v4.0-fast", Query: "q", Documents: []string{"d0", "d1"}, ReturnDocuments: &yes}
|
||||||
|
resp, _, err := gw.Rerank(ctx, req, "")
|
||||||
|
if err != nil || len(resp.Results) != 2 {
|
||||||
|
t.Fatalf("Rerank = %+v, %v(越界 index 应被丢弃)", resp, err)
|
||||||
|
}
|
||||||
|
if resp.Results[0].Index != 1 || resp.Results[0].Document == nil || resp.Results[0].Document.Text != "d1" {
|
||||||
|
t.Fatalf("results[0] = %+v", resp.Results[0])
|
||||||
|
}
|
||||||
|
// 对话模型名打 rerank:能力不匹配 → 未知模型
|
||||||
|
if _, _, err := gw.Rerank(ctx, aiwire.RerankRequest{Model: "meta.llama-3.3-70b-instruct", Query: "q", Documents: []string{"d"}}, ""); !errors.Is(err, ErrAiUnknownModel) {
|
||||||
|
t.Errorf("chat 模型走 rerank err = %v, want ErrAiUnknownModel", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAiSpeech 断言 TTS 编排走 TTS 能力路由并透传音频与 Content-Type。
|
||||||
|
func TestAiSpeech(t *testing.T) {
|
||||||
|
client := &gatewayStubClient{
|
||||||
|
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||||
|
speechAudio: []byte{0xFF, 0xF3}, speechCT: "audio/mpeg",
|
||||||
|
}
|
||||||
|
gw, svc := newTestGateway(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
ch := seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
|
||||||
|
gw.db.Create(&model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..tts", Name: "xai.grok-tts",
|
||||||
|
Vendor: "xai", Capability: "TTS", SyncedAt: time.Now()})
|
||||||
|
audio, ct, _, err := gw.Speech(context.Background(), "xai.grok-tts", []byte(`{"model":"xai.grok-tts","input":"你好","language":"auto"}`), "")
|
||||||
|
if err != nil || ct != "audio/mpeg" || len(audio) != 2 {
|
||||||
|
t.Fatalf("Speech = %d bytes, ct=%s, %v", len(audio), ct, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAiModerations 断言审核编排:无模型维度按分组选渠道,多条输入逐条聚合。
|
||||||
|
func TestAiModerations(t *testing.T) {
|
||||||
|
one := 1.0
|
||||||
|
client := &gatewayStubClient{
|
||||||
|
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||||
|
guardOutcome: &oci.GuardrailsOutcome{Categories: []oci.GuardrailCategory{{Name: "OVERALL", Score: 1}}, PromptInjectionScore: &one},
|
||||||
|
}
|
||||||
|
gw, svc := newTestGateway(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
|
||||||
|
resp, meta, err := gw.Moderations(context.Background(), "modr_test", []string{"a", "b"}, "")
|
||||||
|
if err != nil || len(resp.Results) != 2 || !resp.Results[0].Flagged || resp.ID != "modr_test" {
|
||||||
|
t.Fatalf("Moderations = %+v, meta=%+v, %v", resp, meta, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Model, "guardrails") {
|
||||||
|
t.Errorf("model = %s", resp.Model)
|
||||||
|
}
|
||||||
|
// 分组不匹配 → 无可用渠道
|
||||||
|
if _, _, err := gw.Moderations(context.Background(), "modr_x", []string{"a"}, "ghost-group"); !errors.Is(err, ErrAiNoChannel) {
|
||||||
|
t.Errorf("ghost 分组 err = %v, want ErrAiNoChannel", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSupportedGatewayModels 断言 CHAT 目录仅保留兼容面厂商,EMBEDDING 不受影响。
|
||||||
|
func TestSupportedGatewayModels(t *testing.T) {
|
||||||
|
in := []oci.GenAiModel{
|
||||||
|
{Name: "xai.grok-4.3", Capability: "CHAT"},
|
||||||
|
{Name: "meta.llama-4-maverick-17b-128e", Capability: "CHAT"},
|
||||||
|
{Name: "openai.gpt-oss-120b", Capability: "CHAT"},
|
||||||
|
{Name: "google.gemini-2.5-flash", Capability: "CHAT"},
|
||||||
|
{Name: "cohere.command-a-03-2025", Capability: "CHAT"},
|
||||||
|
{Name: "cohere.embed-v4.0", Capability: "EMBEDDING"},
|
||||||
|
}
|
||||||
|
out := supportedGatewayModels(in)
|
||||||
|
names := make([]string, 0, len(out))
|
||||||
|
for _, m := range out {
|
||||||
|
names = append(names, m.Name)
|
||||||
|
}
|
||||||
|
want := "xai.grok-4.3,meta.llama-4-maverick-17b-128e,openai.gpt-oss-120b,cohere.embed-v4.0"
|
||||||
|
if got := strings.Join(names, ","); got != want {
|
||||||
|
t.Fatalf("过滤结果 = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -13,14 +16,28 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// stubServiceError 实现 common.ServiceError,用于模拟带状态码的 OCI 服务端错误。
|
// stubServiceError 实现 common.ServiceError,用于模拟带状态码的 OCI 服务端错误。
|
||||||
type stubServiceError struct{ status int }
|
type stubServiceError struct {
|
||||||
|
status int
|
||||||
|
msg string
|
||||||
|
}
|
||||||
|
|
||||||
func (e stubServiceError) Error() string { return "stub service error" }
|
func (e stubServiceError) Error() string { return "stub service error" }
|
||||||
func (e stubServiceError) GetHTTPStatusCode() int { return e.status }
|
func (e stubServiceError) GetHTTPStatusCode() int { return e.status }
|
||||||
func (e stubServiceError) GetMessage() string { return "stub" }
|
func (e stubServiceError) GetMessage() string {
|
||||||
|
if e.msg != "" {
|
||||||
|
return e.msg
|
||||||
|
}
|
||||||
|
return "stub"
|
||||||
|
}
|
||||||
func (e stubServiceError) GetCode() string { return "Stub" }
|
func (e stubServiceError) GetCode() string { return "Stub" }
|
||||||
func (e stubServiceError) GetOpcRequestID() string { return "req-1" }
|
func (e stubServiceError) GetOpcRequestID() string { return "req-1" }
|
||||||
|
|
||||||
|
// finetuneBaseErr 模拟「微调基座模型不可按需调用」的 OCI 400。
|
||||||
|
func finetuneBaseErr() stubServiceError {
|
||||||
|
return stubServiceError{status: 400,
|
||||||
|
msg: "Not allowed to call finetune base model ocid1.generativeaimodel.oc1.eu-frankfurt-1.tpel5q, use Endpoint: false"}
|
||||||
|
}
|
||||||
|
|
||||||
// gatewayStubClient 覆写 GenAI 四方法;chatErrs 逐次弹出以模拟先失败后成功。
|
// gatewayStubClient 覆写 GenAI 四方法;chatErrs 逐次弹出以模拟先失败后成功。
|
||||||
type gatewayStubClient struct {
|
type gatewayStubClient struct {
|
||||||
*fakeClient
|
*fakeClient
|
||||||
@@ -29,14 +46,67 @@ type gatewayStubClient struct {
|
|||||||
modelsErr error
|
modelsErr error
|
||||||
probeCode int
|
probeCode int
|
||||||
probeErr error
|
probeErr error
|
||||||
chatResp *aiwire.ChatResponse
|
// probeSeq 非空时逐次弹出,模拟按候选依次试调;弹尽后回落 probeCode/probeErr
|
||||||
chatErrs []error
|
probeSeq []probeResult
|
||||||
chatCalls int
|
// probedNames 记录试调过的模型名,供断言候选过滤
|
||||||
regions []string
|
probedNames []string
|
||||||
|
chatCalls int
|
||||||
|
regions []string
|
||||||
|
|
||||||
embedVecs [][]float32
|
embedVecs [][]float32
|
||||||
embedUsage *aiwire.Usage
|
embedUsage *aiwire.Usage
|
||||||
embedErr error
|
embedErr error
|
||||||
|
|
||||||
|
passPayload []byte
|
||||||
|
passErrs []error
|
||||||
|
passCalls int
|
||||||
|
passRegions []string
|
||||||
|
|
||||||
|
speechAudio []byte
|
||||||
|
speechCT string
|
||||||
|
speechErr error
|
||||||
|
rerankRanks []oci.RerankRank
|
||||||
|
rerankErr error
|
||||||
|
guardOutcome *oci.GuardrailsOutcome
|
||||||
|
guardErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *gatewayStubClient) GenAiCompatSpeech(ctx context.Context, cred oci.Credentials, region string, body []byte) ([]byte, string, error) {
|
||||||
|
return f.speechAudio, f.speechCT, f.speechErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *gatewayStubClient) GenAiRerank(ctx context.Context, cred oci.Credentials, region, modelOcid, query string, documents []string, topN *int) ([]oci.RerankRank, error) {
|
||||||
|
return f.rerankRanks, f.rerankErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *gatewayStubClient) GenAiApplyGuardrails(ctx context.Context, cred oci.Credentials, region, text string) (*oci.GuardrailsOutcome, error) {
|
||||||
|
return f.guardOutcome, f.guardErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *gatewayStubClient) GenAiCompatResponses(ctx context.Context, cred oci.Credentials, region string, body []byte, wait time.Duration) ([]byte, error) {
|
||||||
|
f.passCalls++
|
||||||
|
f.passRegions = append(f.passRegions, region)
|
||||||
|
if len(f.passErrs) > 0 {
|
||||||
|
err := f.passErrs[0]
|
||||||
|
f.passErrs = f.passErrs[1:]
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f.passPayload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *gatewayStubClient) GenAiCompatResponsesStream(ctx context.Context, cred oci.Credentials, region string, body []byte, wait time.Duration) (io.ReadCloser, error) {
|
||||||
|
f.passCalls++
|
||||||
|
f.passRegions = append(f.passRegions, region)
|
||||||
|
if len(f.passErrs) > 0 {
|
||||||
|
err := f.passErrs[0]
|
||||||
|
f.passErrs = f.passErrs[1:]
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return io.NopCloser(bytes.NewReader(f.passPayload)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *gatewayStubClient) GenAiEmbed(ctx context.Context, cred oci.Credentials, region, modelOcid string, inputs []string, dimensions *int) ([][]float32, *aiwire.Usage, error) {
|
func (f *gatewayStubClient) GenAiEmbed(ctx context.Context, cred oci.Credentials, region, modelOcid string, inputs []string, dimensions *int) ([][]float32, *aiwire.Usage, error) {
|
||||||
@@ -47,27 +117,26 @@ func (f *gatewayStubClient) ListGenAiModels(ctx context.Context, cred oci.Creden
|
|||||||
return f.models, f.modelsErr
|
return f.models, f.modelsErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *gatewayStubClient) GenAiProbeChat(ctx context.Context, cred oci.Credentials, region, modelOcid, modelName string) (int, error) {
|
// probeResult 是 gatewayStubClient.probeSeq 的单次探测结果。
|
||||||
return f.probeCode, f.probeErr
|
type probeResult struct {
|
||||||
|
code int
|
||||||
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *gatewayStubClient) GenAiChat(ctx context.Context, cred oci.Credentials, region, modelOcid string, ir aiwire.ChatRequest) (*aiwire.ChatResponse, error) {
|
func (f *gatewayStubClient) GenAiProbeChat(ctx context.Context, cred oci.Credentials, region, modelOcid, modelName string) (int, error) {
|
||||||
f.chatCalls++
|
f.probedNames = append(f.probedNames, modelName)
|
||||||
f.regions = append(f.regions, region)
|
if len(f.probeSeq) > 0 {
|
||||||
if len(f.chatErrs) > 0 {
|
r := f.probeSeq[0]
|
||||||
err := f.chatErrs[0]
|
f.probeSeq = f.probeSeq[1:]
|
||||||
f.chatErrs = f.chatErrs[1:]
|
return r.code, r.err
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return f.chatResp, nil
|
return f.probeCode, f.probeErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestGateway(t *testing.T, client oci.Client) (*AiGatewayService, *OciConfigService) {
|
func newTestGateway(t *testing.T, client oci.Client) (*AiGatewayService, *OciConfigService) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
svc := newTestService(t, client)
|
svc := newTestService(t, client)
|
||||||
if err := svc.db.AutoMigrate(&model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiCallLog{}); err != nil {
|
if err := svc.db.AutoMigrate(&model.Setting{}, &model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiModelBlacklist{}, &model.AiCallLog{}); err != nil {
|
||||||
t.Fatalf("auto migrate ai tables: %v", err)
|
t.Fatalf("auto migrate ai tables: %v", err)
|
||||||
}
|
}
|
||||||
return NewAiGatewayService(svc.db, svc, client), svc
|
return NewAiGatewayService(svc.db, svc, client), svc
|
||||||
@@ -77,14 +146,14 @@ func TestAiKeyLifecycle(t *testing.T) {
|
|||||||
gw, _ := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
gw, _ := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
raw, key, err := gw.CreateKey(ctx, "免费-ai-api-key", "test-api-key", "")
|
raw, key, err := gw.CreateKey(ctx, "免费-ai-api-key", "test-api-key", "", nil)
|
||||||
if err != nil || raw != "test-api-key" || key.Tail != "-key" {
|
if err != nil || raw != "test-api-key" || key.Tail != "-key" {
|
||||||
t.Fatalf("CreateKey 自定义值 = %q %+v, %v", raw, key, err)
|
t.Fatalf("CreateKey 自定义值 = %q %+v, %v", raw, key, err)
|
||||||
}
|
}
|
||||||
if _, _, err := gw.CreateKey(ctx, "short", "abc", ""); err == nil {
|
if _, _, err := gw.CreateKey(ctx, "short", "abc", "", nil); err == nil {
|
||||||
t.Error("过短自定义密钥应被拒绝")
|
t.Error("过短自定义密钥应被拒绝")
|
||||||
}
|
}
|
||||||
auto, _, err := gw.CreateKey(ctx, "auto", "", "")
|
auto, _, err := gw.CreateKey(ctx, "auto", "", "", nil)
|
||||||
if err != nil || len(auto) < 40 || auto[:3] != "sk-" {
|
if err != nil || len(auto) < 40 || auto[:3] != "sk-" {
|
||||||
t.Fatalf("CreateKey 随机值 = %q, %v", auto, err)
|
t.Fatalf("CreateKey 随机值 = %q, %v", auto, err)
|
||||||
}
|
}
|
||||||
@@ -96,7 +165,7 @@ func TestAiKeyLifecycle(t *testing.T) {
|
|||||||
t.Errorf("错误密钥 err = %v", err)
|
t.Errorf("错误密钥 err = %v", err)
|
||||||
}
|
}
|
||||||
off := false
|
off := false
|
||||||
_ = gw.UpdateKey(ctx, key.ID, "", &off, nil)
|
_ = gw.UpdateKey(ctx, key.ID, "", &off, nil, nil)
|
||||||
if _, err := gw.VerifyKey(ctx, "test-api-key"); !errors.Is(err, ErrAiKeyInvalid) {
|
if _, err := gw.VerifyKey(ctx, "test-api-key"); !errors.Is(err, ErrAiKeyInvalid) {
|
||||||
t.Errorf("禁用后 VerifyKey err = %v", err)
|
t.Errorf("禁用后 VerifyKey err = %v", err)
|
||||||
}
|
}
|
||||||
@@ -159,48 +228,43 @@ func seedChannel(t *testing.T, gw *AiGatewayService, cfgID uint, region string,
|
|||||||
return ch
|
return ch
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAiChatRetrySwitchesChannel(t *testing.T) {
|
// TestFilterDeprecatedModels 断言「过滤弃用模型」开关对列表 / 路由的过滤与持久化。
|
||||||
client := &gatewayStubClient{
|
func TestFilterDeprecatedModels(t *testing.T) {
|
||||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
||||||
chatResp: &aiwire.ChatResponse{Model: "meta.llama-3.3-70b-instruct", Choices: []aiwire.Choice{{Message: aiwire.ChatMessage{Role: "assistant", Content: aiwire.NewTextContent("hi")}, FinishReason: "stop"}}},
|
|
||||||
chatErrs: []error{stubServiceError{status: 429}},
|
|
||||||
}
|
|
||||||
gw, svc := newTestGateway(t, client)
|
|
||||||
cfg := importAliveConfig(t, svc)
|
cfg := importAliveConfig(t, svc)
|
||||||
// 两个同优先级渠道
|
ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
|
||||||
seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
|
dep := time.Now().Add(-24 * time.Hour)
|
||||||
seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
|
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()
|
ctx := context.Background()
|
||||||
|
|
||||||
resp, meta, err := gw.Chat(ctx, aiwire.ChatRequest{Model: "meta.llama-3.3-70b-instruct", Messages: []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent("你好")}}}, "")
|
list, _ := gw.GatewayModels(ctx, "")
|
||||||
if err != nil || resp == nil {
|
if len(list.Data) != 2 {
|
||||||
t.Fatalf("Chat = %v, %v", resp, err)
|
t.Fatalf("开关关:应含弃用模型,got %d", len(list.Data))
|
||||||
}
|
}
|
||||||
if meta.Retries != 1 || client.chatCalls != 2 {
|
if err := gw.SetFilterDeprecated(ctx, true); err != nil {
|
||||||
t.Errorf("应换渠道重试一次: retries=%d calls=%d", meta.Retries, client.chatCalls)
|
t.Fatalf("SetFilterDeprecated: %v", err)
|
||||||
}
|
}
|
||||||
if len(client.regions) != 2 && client.regions[0] == client.regions[1] {
|
list, _ = gw.GatewayModels(ctx, "")
|
||||||
t.Errorf("重试未换渠道: %v", client.regions)
|
if len(list.Data) != 1 || list.Data[0].ID != "meta.llama-3.3-70b-instruct" {
|
||||||
|
t.Fatalf("开关开:弃用模型应被过滤,got %+v", list.Data)
|
||||||
}
|
}
|
||||||
// 未知模型
|
if _, err := gw.pick(ctx, "meta.llama-old", "", "CHAT", map[uint]bool{}); !errors.Is(err, ErrAiUnknownModel) {
|
||||||
if _, _, err := gw.Chat(ctx, aiwire.ChatRequest{Model: "no-such-model"}, ""); !errors.Is(err, ErrAiUnknownModel) {
|
t.Errorf("开关开:弃用模型路由应不可达,err = %v", err)
|
||||||
t.Errorf("未知模型 err = %v", err)
|
|
||||||
}
|
}
|
||||||
}
|
// 持久化:重建 service 后开关仍生效
|
||||||
|
gw2 := NewAiGatewayService(gw.db, svc, &gatewayStubClient{})
|
||||||
func TestAiChatNonRetryablePassThrough(t *testing.T) {
|
if !gw2.FilterDeprecated() {
|
||||||
client := &gatewayStubClient{
|
t.Error("重建后开关状态应保持开启")
|
||||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
|
||||||
chatErrs: []error{stubServiceError{status: 400}},
|
|
||||||
}
|
}
|
||||||
gw, svc := newTestGateway(t, client)
|
if err := gw.SetFilterDeprecated(ctx, false); err != nil {
|
||||||
cfg := importAliveConfig(t, svc)
|
t.Fatalf("关闭开关: %v", err)
|
||||||
seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
|
}
|
||||||
seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
|
if list, _ = gw.GatewayModels(ctx, ""); len(list.Data) != 2 {
|
||||||
|
t.Errorf("开关关:弃用模型应恢复,got %d", len(list.Data))
|
||||||
_, meta, err := gw.Chat(context.Background(), aiwire.ChatRequest{Model: "meta.llama-3.3-70b-instruct"}, "")
|
|
||||||
if err == nil || meta.Retries != 0 || client.chatCalls != 1 {
|
|
||||||
t.Errorf("400 应直接透传不重试: err=%v retries=%d calls=%d", err, meta.Retries, client.chatCalls)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,8 +318,8 @@ func TestMarkFailureBackoff(t *testing.T) {
|
|||||||
|
|
||||||
func TestAiGroupRouting(t *testing.T) {
|
func TestAiGroupRouting(t *testing.T) {
|
||||||
client := &gatewayStubClient{
|
client := &gatewayStubClient{
|
||||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||||
chatResp: &aiwire.ChatResponse{Model: "meta.llama-3.3-70b-instruct", Choices: []aiwire.Choice{{Message: aiwire.ChatMessage{Role: "assistant", Content: aiwire.NewTextContent("hi")}, FinishReason: "stop"}}},
|
passPayload: []byte(`{"id":"resp_1","usage":{"input_tokens":3,"output_tokens":1}}`),
|
||||||
}
|
}
|
||||||
gw, svc := newTestGateway(t, client)
|
gw, svc := newTestGateway(t, client)
|
||||||
cfg := importAliveConfig(t, svc)
|
cfg := importAliveConfig(t, svc)
|
||||||
@@ -264,17 +328,17 @@ func TestAiGroupRouting(t *testing.T) {
|
|||||||
gw.db.Model(vip).Update("channel_group", "vip")
|
gw.db.Model(vip).Update("channel_group", "vip")
|
||||||
gw.db.Create(&model.AiModelCache{ChannelID: other.ID, ModelOcid: "ocid1..cr", Name: "cohere.command-r", Vendor: "cohere", SyncedAt: time.Now()})
|
gw.db.Create(&model.AiModelCache{ChannelID: other.ID, ModelOcid: "ocid1..cr", Name: "cohere.command-r", Vendor: "cohere", SyncedAt: time.Now()})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
req := aiwire.ChatRequest{Model: "meta.llama-3.3-70b-instruct", Messages: []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent("你好")}}}
|
body := []byte(`{"model":"meta.llama-3.3-70b-instruct","input":"你好"}`)
|
||||||
|
|
||||||
// 分组密钥只落同分组渠道
|
// 分组密钥只落同分组渠道
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
_, meta, err := gw.Chat(ctx, req, "vip")
|
_, meta, err := gw.RespPassthrough(ctx, body, "meta.llama-3.3-70b-instruct", "vip")
|
||||||
if err != nil || meta.ChannelID != vip.ID {
|
if err != nil || meta.ChannelID != vip.ID {
|
||||||
t.Fatalf("vip 组第 %d 次落点 = %d, err %v, want %d", i, meta.ChannelID, err, vip.ID)
|
t.Fatalf("vip 组第 %d 次落点 = %d, err %v, want %d", i, meta.ChannelID, err, vip.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 分组内无渠道 → 无可用渠道
|
// 分组内无渠道 → 无可用渠道
|
||||||
if _, _, err := gw.Chat(ctx, req, "nope"); !errors.Is(err, ErrAiNoChannel) {
|
if _, _, err := gw.RespPassthrough(ctx, body, "meta.llama-3.3-70b-instruct", "nope"); !errors.Is(err, ErrAiNoChannel) {
|
||||||
t.Errorf("空分组 err = %v, want ErrAiNoChannel", err)
|
t.Errorf("空分组 err = %v, want ErrAiNoChannel", err)
|
||||||
}
|
}
|
||||||
// 模型列表按分组过滤
|
// 模型列表按分组过滤
|
||||||
@@ -287,12 +351,12 @@ func TestAiGroupRouting(t *testing.T) {
|
|||||||
t.Errorf("不限组模型数 = %d, want 2", len(all.Data))
|
t.Errorf("不限组模型数 = %d, want 2", len(all.Data))
|
||||||
}
|
}
|
||||||
// 密钥分组落库
|
// 密钥分组落库
|
||||||
_, key, err := gw.CreateKey(ctx, "vip-key", "vip-secret-1234", "vip")
|
_, key, err := gw.CreateKey(ctx, "vip-key", "vip-secret-1234", "vip", nil)
|
||||||
if err != nil || key.Group != "vip" {
|
if err != nil || key.Group != "vip" {
|
||||||
t.Fatalf("CreateKey group = %+v, %v", key, err)
|
t.Fatalf("CreateKey group = %+v, %v", key, err)
|
||||||
}
|
}
|
||||||
empty := ""
|
empty := ""
|
||||||
_ = gw.UpdateKey(ctx, key.ID, "", nil, &empty)
|
_ = gw.UpdateKey(ctx, key.ID, "", nil, &empty, nil)
|
||||||
var fresh model.AiKey
|
var fresh model.AiKey
|
||||||
gw.db.First(&fresh, key.ID)
|
gw.db.First(&fresh, key.ID)
|
||||||
if fresh.Group != "" {
|
if fresh.Group != "" {
|
||||||
@@ -389,7 +453,7 @@ func TestProbeCandidates(t *testing.T) {
|
|||||||
{Ocid: "o3", Name: "meta.llama-3.3-70b-instruct"},
|
{Ocid: "o3", Name: "meta.llama-3.3-70b-instruct"},
|
||||||
{Ocid: "o4", Name: "google.gemini-2.5-flash"},
|
{Ocid: "o4", Name: "google.gemini-2.5-flash"},
|
||||||
}
|
}
|
||||||
got := probeCandidates(models)
|
got := probeCandidates("", models)
|
||||||
if len(got) != 3 || got[0].Name != "meta.llama-3.3-70b-instruct" || got[1].Name != "google.gemini-2.5-flash" {
|
if len(got) != 3 || got[0].Name != "meta.llama-3.3-70b-instruct" || got[1].Name != "google.gemini-2.5-flash" {
|
||||||
t.Errorf("候选排序 = %+v", got)
|
t.Errorf("候选排序 = %+v", got)
|
||||||
}
|
}
|
||||||
@@ -400,6 +464,367 @@ func TestProbeCandidates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProbeCandidatesVendorDiversity(t *testing.T) {
|
||||||
|
// 部分区域单一厂商全为微调基座:候选须跨厂商分散,不能被 3 个 llama 占满前排
|
||||||
|
models := []oci.GenAiModel{
|
||||||
|
{Ocid: "l1", Name: "meta.llama-3-70b-instruct"},
|
||||||
|
{Ocid: "l2", Name: "meta.llama-3.1-405b-instruct"},
|
||||||
|
{Ocid: "l3", Name: "meta.llama-3.3-70b-instruct"},
|
||||||
|
{Ocid: "c1", Name: "cohere.command-a-03-2025"},
|
||||||
|
{Ocid: "g1", Name: "xai.grok-4"},
|
||||||
|
}
|
||||||
|
got := probeCandidates("", models)
|
||||||
|
if len(got) != 5 || got[0].Name != "meta.llama-3-70b-instruct" {
|
||||||
|
t.Fatalf("上限内全量返回且最高分居首: %+v", got)
|
||||||
|
}
|
||||||
|
vendors := map[string]bool{}
|
||||||
|
for _, m := range got[:3] {
|
||||||
|
vendors[modelVendor(m)] = true
|
||||||
|
}
|
||||||
|
if len(vendors) != 3 {
|
||||||
|
t.Errorf("前 3 个候选应覆盖 3 个厂商: %+v", got)
|
||||||
|
}
|
||||||
|
// 超过上限时截断到 probeCandidateCap
|
||||||
|
var many []oci.GenAiModel
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
many = append(many, oci.GenAiModel{Ocid: fmt.Sprintf("m%d", i), Name: fmt.Sprintf("meta.llama-%d", i)})
|
||||||
|
}
|
||||||
|
if capped := probeCandidates("", many); len(capped) != probeCandidateCap {
|
||||||
|
t.Errorf("候选应截断到 %d: got %d", probeCandidateCap, len(capped))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProbeCandidatesPinsProbeModel 断言探测验证模型置首位且不占常规候选逻辑。
|
||||||
|
func TestProbeCandidatesPinsProbeModel(t *testing.T) {
|
||||||
|
models := []oci.GenAiModel{
|
||||||
|
{Ocid: "o2", Name: "cohere.command-r-plus"},
|
||||||
|
{Ocid: "o3", Name: "meta.llama-3.3-70b-instruct"},
|
||||||
|
{Ocid: "o4", Name: "xai.grok-4"},
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
probeModel string
|
||||||
|
wantFirst string
|
||||||
|
wantLen int
|
||||||
|
}{
|
||||||
|
{"验证模型置首位", "xai.grok-4", "xai.grok-4", 3},
|
||||||
|
{"未设置走常规排序", "", "meta.llama-3.3-70b-instruct", 3},
|
||||||
|
{"验证模型已不在缓存则忽略", "gone.model", "meta.llama-3.3-70b-instruct", 3},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := probeCandidates(tt.probeModel, models)
|
||||||
|
if len(got) != tt.wantLen || got[0].Name != tt.wantFirst {
|
||||||
|
t.Errorf("probeCandidates(%q) = %+v, want first %q", tt.probeModel, got, tt.wantFirst)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// entityNotFoundErr 模拟「实体不存在」404(模型在区域内无按需供给)。
|
||||||
|
func entityNotFoundErr() stubServiceError {
|
||||||
|
return stubServiceError{status: 404,
|
||||||
|
msg: "Entity with key ocid1.generativeaimodel.oc1.eu-frankfurt-1.2flsfq not found"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeSkipsUnavailableModels(t *testing.T) {
|
||||||
|
// 首选 llama 不可按需调用(基座 400 / 实体 404)→ 换候选成功 → 渠道判可用;
|
||||||
|
// 模型全量入库不再自动标记,持久剔除交由用户手动拉黑
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
bad probeResult
|
||||||
|
}{
|
||||||
|
{"微调基座 400", probeResult{400, finetuneBaseErr()}},
|
||||||
|
{"实体不存在 404", probeResult{404, entityNotFoundErr()}},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
client := &gatewayStubClient{
|
||||||
|
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||||
|
models: []oci.GenAiModel{
|
||||||
|
{Ocid: "m1", Name: "meta.llama-3-70b-instruct", Vendor: "meta"},
|
||||||
|
{Ocid: "m2", Name: "meta.llama-3.1-70b-instruct", Vendor: "meta"},
|
||||||
|
{Ocid: "m3", Name: "cohere.command-a-03-2025", Vendor: "cohere"},
|
||||||
|
},
|
||||||
|
probeSeq: []probeResult{tt.bad, {200, nil}},
|
||||||
|
}
|
||||||
|
gw, svc := newTestGateway(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
ch, err := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "eu-frankfurt-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateChannel: %v", err)
|
||||||
|
}
|
||||||
|
probed, err := gw.ProbeChannel(ctx, ch.ID)
|
||||||
|
if err != nil || probed.ProbeStatus != "ok" {
|
||||||
|
t.Fatalf("坏候选后应换候选并判可用: %+v, %v", probed, err)
|
||||||
|
}
|
||||||
|
rows, _ := gw.channelModels(ctx, ch.ID)
|
||||||
|
if len(rows) != 3 {
|
||||||
|
t.Errorf("模型应全量入库, got %d", len(rows))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeAuth404StillNoQuota(t *testing.T) {
|
||||||
|
// 鉴权类 404(NotAuthorizedOrNotFound)仍属租户级,直接定论 no_quota
|
||||||
|
client := &gatewayStubClient{
|
||||||
|
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||||
|
models: []oci.GenAiModel{{Ocid: "m1", Name: "meta.llama-3.3-70b-instruct", Vendor: "meta"}},
|
||||||
|
probeCode: 404,
|
||||||
|
probeErr: stubServiceError{status: 404, msg: "Authorization failed or requested resource not found."},
|
||||||
|
}
|
||||||
|
gw, svc := newTestGateway(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
ch, _ := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "eu-frankfurt-1"})
|
||||||
|
probed, _ := gw.ProbeChannel(ctx, ch.ID)
|
||||||
|
if probed.ProbeStatus != "no_quota" {
|
||||||
|
t.Errorf("鉴权 404 status = %q, want no_quota", probed.ProbeStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChannelModelsExcludeBlacklist 断言模型列表与数量统计对黑名单做查询层兜底过滤。
|
||||||
|
func TestChannelModelsExcludeBlacklist(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)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 直插黑名单行但保留缓存,模拟脏数据 / 并发窗口
|
||||||
|
gw.db.Create(&model.AiModelBlacklist{Name: "meta.llama-3.3-70b-instruct"})
|
||||||
|
rows, err := gw.ChannelModels(ctx, ch.ID)
|
||||||
|
if err != nil || len(rows) != 0 {
|
||||||
|
t.Errorf("黑名单模型应被过滤: %+v, %v", rows, err)
|
||||||
|
}
|
||||||
|
chs, err := gw.Channels(ctx)
|
||||||
|
if err != nil || len(chs) != 1 || chs[0].ModelCount != 0 {
|
||||||
|
t.Errorf("模型数量统计应剔除黑名单: %+v, %v", chs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChannelModelsFilterDeprecated 断言「过滤弃用模型」开关同样作用于
|
||||||
|
// 渠道模型列表与数量统计(数据保留,展示口径过滤)。
|
||||||
|
func TestChannelModelsFilterDeprecated(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)
|
||||||
|
gw.db.Create(&model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..dep", Name: "xai.grok-3",
|
||||||
|
Vendor: "xai", SyncedAt: time.Now(), DeprecatedAt: &dep})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
rows, _ := gw.ChannelModels(ctx, ch.ID)
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Fatalf("开关关:弃用模型应在列,got %d", len(rows))
|
||||||
|
}
|
||||||
|
if err := gw.SetFilterDeprecated(ctx, true); err != nil {
|
||||||
|
t.Fatalf("SetFilterDeprecated: %v", err)
|
||||||
|
}
|
||||||
|
rows, _ = gw.ChannelModels(ctx, ch.ID)
|
||||||
|
if len(rows) != 1 || rows[0].Name != "meta.llama-3.3-70b-instruct" {
|
||||||
|
t.Errorf("开关开:弃用模型应被过滤,got %+v", rows)
|
||||||
|
}
|
||||||
|
chs, _ := gw.Channels(ctx)
|
||||||
|
if len(chs) != 1 || chs[0].ModelCount != 1 {
|
||||||
|
t.Errorf("开关开:数量统计应同口径,got %+v", chs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProbe403ContinuesToNextCandidate 断言模型级 403 不再武断定论渠道无配额:
|
||||||
|
// 后续候选成功 → ok;全部候选鉴权拒绝 → 仍 no_quota。
|
||||||
|
func TestProbe403ContinuesToNextCandidate(t *testing.T) {
|
||||||
|
deny := probeResult{403, stubServiceError{status: 403, msg: "NotAuthorizedOrNotFound"}}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
seq []probeResult
|
||||||
|
wantStatus string
|
||||||
|
}{
|
||||||
|
{"403 后换候选成功", []probeResult{deny, {200, nil}}, "ok"},
|
||||||
|
{"403 后换候选限流也算可用", []probeResult{deny, {429, stubServiceError{status: 429}}}, "ok"},
|
||||||
|
{"全部候选 403", []probeResult{deny, deny}, "no_quota"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
client := &gatewayStubClient{
|
||||||
|
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||||
|
models: []oci.GenAiModel{
|
||||||
|
{Ocid: "m1", Name: "meta.llama-3.3-70b-instruct", Vendor: "meta"},
|
||||||
|
{Ocid: "m2", Name: "cohere.command-a-03-2025", Vendor: "cohere"},
|
||||||
|
},
|
||||||
|
probeSeq: tt.seq,
|
||||||
|
}
|
||||||
|
gw, svc := newTestGateway(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
ch, _ := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "eu-frankfurt-1"})
|
||||||
|
probed, err := gw.ProbeChannel(ctx, ch.ID)
|
||||||
|
if err != nil || probed.ProbeStatus != tt.wantStatus {
|
||||||
|
t.Fatalf("ProbeChannel = %+v, %v, want %q", probed, err, tt.wantStatus)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChannelModelTest 断言单模型试调:通过时写探测验证模型并翻转状态,失败不动。
|
||||||
|
func TestChannelModelTest(t *testing.T) {
|
||||||
|
client := &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
|
||||||
|
gw, svc := newTestGateway(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
|
||||||
|
gw.db.Model(ch).Updates(map[string]any{"probe_status": "no_quota", "probe_error": "旧错误", "fail_count": 6})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := gw.TestChannelModel(ctx, ch.ID, "not.exists"); err == nil {
|
||||||
|
t.Error("缓存外模型应报错")
|
||||||
|
}
|
||||||
|
client.probeCode, client.probeErr = 403, stubServiceError{status: 403}
|
||||||
|
if _, err := gw.TestChannelModel(ctx, ch.ID, "meta.llama-3.3-70b-instruct"); err == nil {
|
||||||
|
t.Error("403 试调应报测试未通过")
|
||||||
|
}
|
||||||
|
var still model.AiChannel
|
||||||
|
gw.db.First(&still, ch.ID)
|
||||||
|
if still.ProbeStatus != "no_quota" || still.ProbeModel != "" {
|
||||||
|
t.Errorf("失败不应改动渠道: %+v", still)
|
||||||
|
}
|
||||||
|
client.probeCode, client.probeErr = 200, nil
|
||||||
|
fresh, err := gw.TestChannelModel(ctx, ch.ID, "meta.llama-3.3-70b-instruct")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TestChannelModel: %v", err)
|
||||||
|
}
|
||||||
|
if fresh.ProbeModel != "meta.llama-3.3-70b-instruct" || fresh.ProbeStatus != "ok" ||
|
||||||
|
fresh.ProbeError != "" || fresh.FailCount != 0 {
|
||||||
|
t.Errorf("通过后应写验证模型并置可用: %+v", fresh)
|
||||||
|
}
|
||||||
|
// 已 ok 渠道再测另一模型:仅更新验证模型,不重写探测时间
|
||||||
|
cache2 := &model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..m2", Name: "xai.grok-4", Vendor: "xai", SyncedAt: time.Now()}
|
||||||
|
gw.db.Create(cache2)
|
||||||
|
fresh2, err := gw.TestChannelModel(ctx, ch.ID, "xai.grok-4")
|
||||||
|
if err != nil || fresh2.ProbeModel != "xai.grok-4" {
|
||||||
|
t.Fatalf("已可用渠道更新验证模型: %+v, %v", fresh2, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAiChatFinetuneSwitchesChannelWithoutPenalty(t *testing.T) {
|
||||||
|
// 微调基座 400 换渠道重试成功,且不计入熔断失败
|
||||||
|
client := &gatewayStubClient{
|
||||||
|
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||||
|
passPayload: []byte(`{"id":"resp_1","usage":{"input_tokens":3,"output_tokens":1}}`),
|
||||||
|
passErrs: []error{finetuneBaseErr()},
|
||||||
|
}
|
||||||
|
gw, svc := newTestGateway(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
|
||||||
|
seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
|
||||||
|
|
||||||
|
resp, meta, err := gw.RespPassthrough(context.Background(), []byte(`{}`), "meta.llama-3.3-70b-instruct", "")
|
||||||
|
if err != nil || resp == nil {
|
||||||
|
t.Fatalf("RespPassthrough = %v, %v", resp, err)
|
||||||
|
}
|
||||||
|
if meta.Retries != 1 || client.passCalls != 2 {
|
||||||
|
t.Errorf("应换渠道重试一次: retries=%d calls=%d", meta.Retries, client.passCalls)
|
||||||
|
}
|
||||||
|
var chs []model.AiChannel
|
||||||
|
gw.db.Find(&chs)
|
||||||
|
for _, ch := range chs {
|
||||||
|
if ch.FailCount != 0 {
|
||||||
|
t.Errorf("微调基座 400 不应计入熔断: 渠道 %s failCount=%d", ch.Name, ch.FailCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlacklistLifecycle(t *testing.T) {
|
||||||
|
// 拉黑:全渠道同名缓存删除、列表与路由立即不可见;重复/空名被拒;
|
||||||
|
// 移出黑名单后重新同步恢复入池
|
||||||
|
client := &gatewayStubClient{
|
||||||
|
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||||
|
models: []oci.GenAiModel{{Ocid: "ocid1..m-eu-frankfurt-1", Name: "meta.llama-3.3-70b-instruct", Vendor: "meta"}},
|
||||||
|
}
|
||||||
|
gw, svc := newTestGateway(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
|
||||||
|
seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
item, err := gw.AddBlacklist(ctx, " meta.llama-3.3-70b-instruct ")
|
||||||
|
if err != nil || item.Name != "meta.llama-3.3-70b-instruct" {
|
||||||
|
t.Fatalf("AddBlacklist = %+v, %v", item, err)
|
||||||
|
}
|
||||||
|
var left int64
|
||||||
|
gw.db.Model(&model.AiModelCache{}).Count(&left)
|
||||||
|
if left != 0 {
|
||||||
|
t.Errorf("拉黑应删除全部渠道的同名缓存, 剩 %d", left)
|
||||||
|
}
|
||||||
|
if _, _, err := gw.RespPassthrough(ctx, []byte(`{}`), "meta.llama-3.3-70b-instruct", ""); !errors.Is(err, ErrAiUnknownModel) {
|
||||||
|
t.Errorf("拉黑后路由应按未知模型拒绝: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := gw.AddBlacklist(ctx, "meta.llama-3.3-70b-instruct"); err == nil {
|
||||||
|
t.Error("重复拉黑应被拒绝")
|
||||||
|
}
|
||||||
|
if _, err := gw.AddBlacklist(ctx, " "); err == nil {
|
||||||
|
t.Error("空模型名应被拒绝")
|
||||||
|
}
|
||||||
|
rows, err := gw.Blacklist(ctx)
|
||||||
|
if err != nil || len(rows) != 1 {
|
||||||
|
t.Fatalf("Blacklist = %+v, %v", rows, err)
|
||||||
|
}
|
||||||
|
if err := gw.RemoveBlacklist(ctx, rows[0].ID); err != nil {
|
||||||
|
t.Fatalf("RemoveBlacklist: %v", err)
|
||||||
|
}
|
||||||
|
if err := gw.RemoveBlacklist(ctx, rows[0].ID); err == nil {
|
||||||
|
t.Error("移除不存在的条目应报错")
|
||||||
|
}
|
||||||
|
// 移出后重新同步:模型恢复入池
|
||||||
|
if _, err := gw.SyncModels(ctx, ch.ID); err != nil {
|
||||||
|
t.Fatalf("SyncModels: %v", err)
|
||||||
|
}
|
||||||
|
list, _ := gw.GatewayModels(ctx, "")
|
||||||
|
if len(list.Data) != 1 {
|
||||||
|
t.Errorf("移出黑名单并同步后应恢复入池: %+v", list.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncAndProbeFilterBlacklisted(t *testing.T) {
|
||||||
|
// 同步与探测都过滤黑名单模型:不入库、不进试调候选
|
||||||
|
client := &gatewayStubClient{
|
||||||
|
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||||
|
models: []oci.GenAiModel{
|
||||||
|
{Ocid: "m1", Name: "meta.llama-3-70b-instruct", Vendor: "meta"},
|
||||||
|
{Ocid: "m2", Name: "cohere.command-a-03-2025", Vendor: "cohere"},
|
||||||
|
},
|
||||||
|
probeCode: 200,
|
||||||
|
}
|
||||||
|
gw, svc := newTestGateway(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := gw.AddBlacklist(ctx, "meta.llama-3-70b-instruct"); err != nil {
|
||||||
|
t.Fatalf("AddBlacklist: %v", err)
|
||||||
|
}
|
||||||
|
ch, err := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "eu-frankfurt-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateChannel: %v", err)
|
||||||
|
}
|
||||||
|
probed, err := gw.ProbeChannel(ctx, ch.ID)
|
||||||
|
if err != nil || probed.ProbeStatus != "ok" {
|
||||||
|
t.Fatalf("ProbeChannel = %+v, %v", probed, err)
|
||||||
|
}
|
||||||
|
rows, _ := gw.channelModels(ctx, ch.ID)
|
||||||
|
if len(rows) != 1 || rows[0].Name != "cohere.command-a-03-2025" {
|
||||||
|
t.Errorf("探测同步应过滤黑名单模型: %+v", rows)
|
||||||
|
}
|
||||||
|
if len(client.probedNames) != 1 || client.probedNames[0] != "cohere.command-a-03-2025" {
|
||||||
|
t.Errorf("试调候选不应包含黑名单模型: %v", client.probedNames)
|
||||||
|
}
|
||||||
|
models, err := gw.SyncModels(ctx, ch.ID)
|
||||||
|
if err != nil || len(models) != 1 || models[0].Name != "cohere.command-a-03-2025" {
|
||||||
|
t.Errorf("SyncModels 应过滤黑名单模型: %+v, %v", models, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeprecatingModels(t *testing.T) {
|
func TestDeprecatingModels(t *testing.T) {
|
||||||
gw, _ := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
gw, _ := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -460,7 +885,7 @@ func TestAiEmbeddings(t *testing.T) {
|
|||||||
t.Errorf("chat 模型走 embeddings err = %v, want ErrAiUnknownModel", err)
|
t.Errorf("chat 模型走 embeddings err = %v, want ErrAiUnknownModel", err)
|
||||||
}
|
}
|
||||||
// embedding 模型名打 chat:同样未知模型
|
// embedding 模型名打 chat:同样未知模型
|
||||||
if _, _, err := gw.Chat(ctx, aiwire.ChatRequest{Model: "cohere.embed-v4.0", Messages: []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent("x")}}}, ""); !errors.Is(err, ErrAiUnknownModel) {
|
if _, _, err := gw.RespPassthrough(ctx, []byte(`{}`), "cohere.embed-v4.0", ""); !errors.Is(err, ErrAiUnknownModel) {
|
||||||
t.Errorf("embedding 模型走 chat err = %v, want ErrAiUnknownModel", err)
|
t.Errorf("embedding 模型走 chat err = %v, want ErrAiUnknownModel", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -471,7 +896,7 @@ func TestAiContentLogSwitch(t *testing.T) {
|
|||||||
t.Fatalf("migrate content log: %v", err)
|
t.Fatalf("migrate content log: %v", err)
|
||||||
}
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
_, key, err := gw.CreateKey(ctx, "k1", "content-key-1234", "")
|
_, key, err := gw.CreateKey(ctx, "k1", "content-key-1234", "", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateKey: %v", err)
|
t.Fatalf("CreateKey: %v", err)
|
||||||
}
|
}
|
||||||
@@ -507,3 +932,230 @@ func TestAiContentLogSwitch(t *testing.T) {
|
|||||||
t.Fatalf("关闭失败: %+v, %v", fresh.ContentLogUntil, err)
|
t.Fatalf("关闭失败: %+v, %v", fresh.ContentLogUntil, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRespPassthroughSwitchesChannel(t *testing.T) {
|
||||||
|
client := &gatewayStubClient{
|
||||||
|
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||||
|
passPayload: []byte(`{"id":"resp_1","usage":{"input_tokens":9,"output_tokens":3}}`),
|
||||||
|
passErrs: []error{stubServiceError{status: 429}},
|
||||||
|
}
|
||||||
|
gw, svc := newTestGateway(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
|
||||||
|
seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
|
||||||
|
|
||||||
|
payload, meta, err := gw.RespPassthrough(context.Background(), []byte(`{}`), "meta.llama-3.3-70b-instruct", "")
|
||||||
|
if err != nil || len(payload) == 0 {
|
||||||
|
t.Fatalf("RespPassthrough = %v, %v", payload, err)
|
||||||
|
}
|
||||||
|
if meta.Retries != 1 || client.passCalls != 2 {
|
||||||
|
t.Errorf("应换渠道重试一次: retries=%d calls=%d", meta.Retries, client.passCalls)
|
||||||
|
}
|
||||||
|
if len(client.passRegions) == 2 && client.passRegions[0] == client.passRegions[1] {
|
||||||
|
t.Errorf("重试未换渠道: %v", client.passRegions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRespPassthroughNonRetryable(t *testing.T) {
|
||||||
|
client := &gatewayStubClient{
|
||||||
|
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||||
|
passErrs: []error{stubServiceError{status: 400}},
|
||||||
|
}
|
||||||
|
gw, svc := newTestGateway(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
|
||||||
|
seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
|
||||||
|
|
||||||
|
_, meta, err := gw.RespPassthrough(context.Background(), []byte(`{}`), "meta.llama-3.3-70b-instruct", "")
|
||||||
|
if err == nil || meta.Retries != 0 || client.passCalls != 1 {
|
||||||
|
t.Errorf("400 应直接透传不重试: err=%v retries=%d calls=%d", err, meta.Retries, client.passCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeKeyModels(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in []string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"nil 输入", nil, nil},
|
||||||
|
{"全空串", []string{"", " "}, nil},
|
||||||
|
{"trim 与保序去重", []string{" a ", "b", "a", "", "b "}, []string{"a", "b"}},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := normalizeKeyModels(tt.in)
|
||||||
|
if len(got) != len(tt.want) {
|
||||||
|
t.Fatalf("normalizeKeyModels(%v) = %v, want %v", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
for i := range tt.want {
|
||||||
|
if got[i] != tt.want[i] {
|
||||||
|
t.Fatalf("normalizeKeyModels(%v) = %v, want %v", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAiKeyModelsPersistence 钉住 serializer:json 字段经 Create 与 Updates(map) 两条路径的往返。
|
||||||
|
func TestAiKeyModelsPersistence(t *testing.T) {
|
||||||
|
gw, _ := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, key, err := gw.CreateKey(ctx, "m-key", "model-key-1234", "", []string{" x-model ", "x-model", "y-model", ""})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateKey: %v", err)
|
||||||
|
}
|
||||||
|
fresh, err := gw.VerifyKey(ctx, "model-key-1234")
|
||||||
|
if err != nil || len(fresh.Models) != 2 || fresh.Models[0] != "x-model" || fresh.Models[1] != "y-model" {
|
||||||
|
t.Fatalf("创建后读回 models = %v, %v", fresh.Models, err)
|
||||||
|
}
|
||||||
|
set := []string{"z-model"}
|
||||||
|
if err := gw.UpdateKey(ctx, key.ID, "", nil, nil, &set); err != nil {
|
||||||
|
t.Fatalf("UpdateKey 覆盖: %v", err)
|
||||||
|
}
|
||||||
|
fresh, _ = gw.VerifyKey(ctx, "model-key-1234")
|
||||||
|
if len(fresh.Models) != 1 || fresh.Models[0] != "z-model" {
|
||||||
|
t.Fatalf("覆盖后 models = %v", fresh.Models)
|
||||||
|
}
|
||||||
|
if err := gw.UpdateKey(ctx, key.ID, "renamed", nil, nil, nil); err != nil {
|
||||||
|
t.Fatalf("UpdateKey 不传 models: %v", err)
|
||||||
|
}
|
||||||
|
fresh, _ = gw.VerifyKey(ctx, "model-key-1234")
|
||||||
|
if len(fresh.Models) != 1 {
|
||||||
|
t.Fatalf("未传 models 却被改动: %v", fresh.Models)
|
||||||
|
}
|
||||||
|
empty := []string{}
|
||||||
|
if err := gw.UpdateKey(ctx, key.ID, "", nil, nil, &empty); err != nil {
|
||||||
|
t.Fatalf("UpdateKey 清空: %v", err)
|
||||||
|
}
|
||||||
|
fresh, _ = gw.VerifyKey(ctx, "model-key-1234")
|
||||||
|
if len(fresh.Models) != 0 {
|
||||||
|
t.Fatalf("清空后 models = %v, want 空", fresh.Models)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRespPassthroughStreamSwitchesChannel 断言流式直通建立失败按 switchable 换渠道。
|
||||||
|
func TestRespPassthroughStreamSwitchesChannel(t *testing.T) {
|
||||||
|
client := &gatewayStubClient{
|
||||||
|
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||||
|
passPayload: []byte("data: {\"type\":\"response.completed\"}\n\n"),
|
||||||
|
passErrs: []error{stubServiceError{status: 503}},
|
||||||
|
}
|
||||||
|
gw, svc := newTestGateway(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
|
||||||
|
seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
|
||||||
|
|
||||||
|
stream, meta, err := gw.RespPassthroughStream(context.Background(), []byte(`{}`), "meta.llama-3.3-70b-instruct", "")
|
||||||
|
if err != nil || stream == nil {
|
||||||
|
t.Fatalf("RespPassthroughStream = %v", err)
|
||||||
|
}
|
||||||
|
defer stream.Close()
|
||||||
|
if meta.Retries != 1 || client.passCalls != 2 {
|
||||||
|
t.Errorf("应换渠道重试一次: retries=%d calls=%d", meta.Retries, client.passCalls)
|
||||||
|
}
|
||||||
|
payload, _ := io.ReadAll(stream)
|
||||||
|
if !bytes.Contains(payload, []byte("response.completed")) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+539
-191
@@ -8,38 +8,8 @@ import (
|
|||||||
"oci-portal/internal/aiwire"
|
"oci-portal/internal/aiwire"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ResponsesToIR 把 Responses API 请求转换为网关 IR(OpenAI Chat 形态)。
|
// respRejectStateful 拒绝有状态特性(网关无状态)。
|
||||||
// 有状态特性与内置工具不支持,直接报错(API 层 400)。
|
func respRejectStateful(req aiwire.RespRequest) error {
|
||||||
func ResponsesToIR(req aiwire.RespRequest) (aiwire.ChatRequest, error) {
|
|
||||||
if err := respRejectUnsupported(req); err != nil {
|
|
||||||
return aiwire.ChatRequest{}, err
|
|
||||||
}
|
|
||||||
ir := aiwire.ChatRequest{
|
|
||||||
Model: req.Model,
|
|
||||||
MaxTokens: req.MaxOutputTokens,
|
|
||||||
Temperature: req.Temperature,
|
|
||||||
TopP: req.TopP,
|
|
||||||
Stream: req.Stream,
|
|
||||||
ToolChoice: respToolChoice(req.ToolChoice),
|
|
||||||
}
|
|
||||||
if req.Reasoning != nil {
|
|
||||||
ir.ReasoningEffort = req.Reasoning.Effort
|
|
||||||
}
|
|
||||||
if req.Instructions != "" {
|
|
||||||
ir.Messages = append(ir.Messages, aiwire.ChatMessage{Role: "system", Content: aiwire.NewTextContent(req.Instructions)})
|
|
||||||
}
|
|
||||||
msgs, err := respInputToMessages(req.Input)
|
|
||||||
if err != nil {
|
|
||||||
return aiwire.ChatRequest{}, err
|
|
||||||
}
|
|
||||||
ir.Messages = append(ir.Messages, msgs...)
|
|
||||||
ir.Tools = respTools(req.Tools)
|
|
||||||
ir.ResponseFormat = respFormat(req.Text)
|
|
||||||
return ir, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// respRejectUnsupported 拒绝无状态网关无法承接的请求特性。
|
|
||||||
func respRejectUnsupported(req aiwire.RespRequest) error {
|
|
||||||
if req.PreviousResponseID != "" {
|
if req.PreviousResponseID != "" {
|
||||||
return fmt.Errorf("previous_response_id 不支持:网关不保存历史响应,请在 input 中自带完整上下文(store:false 模式)")
|
return fmt.Errorf("previous_response_id 不支持:网关不保存历史响应,请在 input 中自带完整上下文(store:false 模式)")
|
||||||
}
|
}
|
||||||
@@ -49,195 +19,573 @@ func respRejectUnsupported(req aiwire.RespRequest) error {
|
|||||||
if req.Background != nil && *req.Background {
|
if req.Background != nil && *req.Background {
|
||||||
return fmt.Errorf("background 模式不支持")
|
return fmt.Errorf("background 模式不支持")
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RespPassthroughValidate 校验直通请求:模型必填,有状态特性不支持,工具类型
|
||||||
|
// 放行 function、Oracle 文档化的服务端工具(web_search / x_search /
|
||||||
|
// code_interpreter / mcp)与 codex 特有形态(namespace 工具分组、custom 自由
|
||||||
|
// 格式,上游不识别,转发前拍平/转换)。
|
||||||
|
func RespPassthroughValidate(req aiwire.RespRequest) error {
|
||||||
|
if strings.TrimSpace(req.Model) == "" {
|
||||||
|
return fmt.Errorf("model 不能为空")
|
||||||
|
}
|
||||||
|
if err := respRejectStateful(req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
for _, t := range req.Tools {
|
for _, t := range req.Tools {
|
||||||
if t.Type != "function" {
|
switch t.Type {
|
||||||
return fmt.Errorf("不支持的工具类型 %q:仅支持 function 工具", t.Type)
|
case "function", "web_search", "x_search", "code_interpreter", "mcp", "namespace", "custom", "tool_search":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("不支持的工具类型 %q:服务端工具仅支持 web_search / x_search / code_interpreter / mcp", t.Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// respInputToMessages 把 input(string 或 item 数组)展开为 IR 消息序列。
|
// RespNsRef 一条拍平映射:限定名对应的原 namespace 与短名。codex 的回程路由
|
||||||
func respInputToMessages(in aiwire.RespInput) ([]aiwire.ChatMessage, error) {
|
// 依赖 function_call 项上的 name+namespace 双字段,响应侧按此映射还原。
|
||||||
if !in.IsArray {
|
type RespNsRef struct {
|
||||||
if strings.TrimSpace(in.Text) == "" {
|
Namespace string
|
||||||
return nil, fmt.Errorf("input 不能为空")
|
Name string
|
||||||
}
|
}
|
||||||
return []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent(in.Text)}}, nil
|
|
||||||
|
// RespCompat 是直通请求 codex 兼容改写的结果:Flattened/Converted/Dropped 供
|
||||||
|
// 调用方记日志;NsRefs / CustomNames 非空表示响应侧需做工具调用项还原。
|
||||||
|
type RespCompat struct {
|
||||||
|
Flattened []string
|
||||||
|
Converted []string
|
||||||
|
Dropped []string
|
||||||
|
NsRefs map[string]RespNsRef
|
||||||
|
CustomNames map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedRestore 报告响应是否需要做工具调用项还原。
|
||||||
|
func (c RespCompat) NeedRestore() bool {
|
||||||
|
return len(c.NsRefs) > 0 || len(c.CustomNames) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// RespPassthroughBody 以原始请求体为基构造上游 body:强制 store:false(禁上游
|
||||||
|
// 存态),stream 原样保留(流式直通);用 json.Number 保真未知字段与数值。
|
||||||
|
// 同时做 codex 兼容改写(namespace 拍平、多轮历史与 tool_choice 重限定)。
|
||||||
|
func RespPassthroughBody(raw []byte) ([]byte, RespCompat, error) {
|
||||||
|
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||||
|
dec.UseNumber()
|
||||||
|
var body map[string]any
|
||||||
|
if err := dec.Decode(&body); err != nil {
|
||||||
|
return nil, RespCompat{}, fmt.Errorf("解析请求体: %w", err)
|
||||||
}
|
}
|
||||||
var msgs []aiwire.ChatMessage
|
body["store"] = false
|
||||||
for _, it := range in.Items {
|
compat := respCompatTools(body)
|
||||||
out, err := respItemToMessages(it, msgs)
|
respCompatInputCalls(body)
|
||||||
|
respCompatToolChoice(body)
|
||||||
|
out, err := json.Marshal(body)
|
||||||
|
return out, compat, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// respCompatTools 对 tools 做 codex 兼容改写(上游实测行为见任务档案):
|
||||||
|
// namespace 工具组上游 422,拆平为限定名 function 工具;custom(自由格式)上游
|
||||||
|
// 同样 422,apply_patch 丢弃(grok 系未训练该补丁格式,失败编辑不如 shell 回退),
|
||||||
|
// 其余转 function 包装;web_search 的 external_web_access 参数上游 400,false 是
|
||||||
|
// "仅缓存检索"降权模式,按不越权原则连工具剥离,true 等价默认行为仅删键;
|
||||||
|
// 剔空后删 tools,并连删 tool_choice 与 parallel_tool_calls(上游拒绝无 tools
|
||||||
|
// 带 tool_choice)。
|
||||||
|
func respCompatTools(body map[string]any) RespCompat {
|
||||||
|
tools, ok := body["tools"].([]any)
|
||||||
|
if !ok {
|
||||||
|
return RespCompat{}
|
||||||
|
}
|
||||||
|
compat := RespCompat{NsRefs: map[string]RespNsRef{}, CustomNames: map[string]struct{}{}}
|
||||||
|
kept := make([]any, 0, len(tools))
|
||||||
|
for _, item := range tools {
|
||||||
|
tool, _ := item.(map[string]any)
|
||||||
|
switch {
|
||||||
|
case tool != nil && tool["type"] == "namespace":
|
||||||
|
ns, _ := tool["name"].(string)
|
||||||
|
compat.Flattened = append(compat.Flattened, "namespace:"+ns)
|
||||||
|
kept = append(kept, respFlattenNsTool(ns, tool, &compat)...)
|
||||||
|
continue
|
||||||
|
case tool != nil && tool["type"] == "custom":
|
||||||
|
name, _ := tool["name"].(string)
|
||||||
|
if name == "apply_patch" {
|
||||||
|
compat.Dropped = append(compat.Dropped, "custom:apply_patch")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
respConvertCustomTool(tool)
|
||||||
|
if name != "" {
|
||||||
|
compat.CustomNames[name] = struct{}{}
|
||||||
|
compat.Converted = append(compat.Converted, "custom:"+name)
|
||||||
|
}
|
||||||
|
kept = append(kept, tool)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if desc, drop := respToolDrop(tool); drop {
|
||||||
|
compat.Dropped = append(compat.Dropped, desc)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, item)
|
||||||
|
}
|
||||||
|
if len(kept) == 0 {
|
||||||
|
delete(body, "tools")
|
||||||
|
delete(body, "tool_choice")
|
||||||
|
delete(body, "parallel_tool_calls")
|
||||||
|
} else {
|
||||||
|
body["tools"] = kept
|
||||||
|
}
|
||||||
|
return compat
|
||||||
|
}
|
||||||
|
|
||||||
|
// respConvertCustomTool 把 custom(自由格式)工具改写为 function:补 input 包装
|
||||||
|
// schema(custom 工具本无 parameters),模型以 {"input": 文本} 形态调用,响应侧
|
||||||
|
// 按 CustomNames 回转;custom 专有的 format(语法约束)字段一并移除。
|
||||||
|
func respConvertCustomTool(tool map[string]any) {
|
||||||
|
tool["type"] = "function"
|
||||||
|
delete(tool, "format")
|
||||||
|
if _, ok := tool["parameters"]; !ok {
|
||||||
|
tool["parameters"] = map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{"input": map[string]any{"type": "string"}},
|
||||||
|
"required": []any{"input"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// respFlattenNsTool 把 namespace 组内子工具上提为顶层工具:限定改名、缺
|
||||||
|
// parameters 补空 schema(上游必填),映射写入 NsRefs 供响应侧还原;非
|
||||||
|
// function 子工具(如 custom)上游同样不认,剥离并记录。
|
||||||
|
func respFlattenNsTool(ns string, tool map[string]any, compat *RespCompat) []any {
|
||||||
|
children, _ := tool["tools"].([]any)
|
||||||
|
out := make([]any, 0, len(children))
|
||||||
|
for _, c := range children {
|
||||||
|
child, _ := c.(map[string]any)
|
||||||
|
if child == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
short, _ := child["name"].(string)
|
||||||
|
if t, _ := child["type"].(string); t != "" && t != "function" {
|
||||||
|
compat.Dropped = append(compat.Dropped, ns+"."+short+"(type="+t+")")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
qualified := respQualifyNsName(ns, short)
|
||||||
|
if qualified == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
child["name"] = qualified
|
||||||
|
if _, ok := child["parameters"]; !ok {
|
||||||
|
child["parameters"] = map[string]any{"type": "object", "properties": map[string]any{}}
|
||||||
|
}
|
||||||
|
compat.NsRefs[qualified] = RespNsRef{Namespace: ns, Name: short}
|
||||||
|
out = append(out, child)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// respQualifyNsName 生成拍平后的限定名,与响应侧还原互逆(对齐 CLIProxyAPI):
|
||||||
|
// mcp__ 开头的子工具名自带全局前缀不再限定;ns 以 __ 结尾直接拼接;已带前缀
|
||||||
|
// 不重复添加。
|
||||||
|
func respQualifyNsName(ns, name string) string {
|
||||||
|
ns, name = strings.TrimSpace(ns), strings.TrimSpace(name)
|
||||||
|
if ns == "" || name == "" || strings.HasPrefix(name, "mcp__") {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
prefix := ns
|
||||||
|
if !strings.HasSuffix(prefix, "__") {
|
||||||
|
prefix += "__"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(name, prefix) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return prefix + name
|
||||||
|
}
|
||||||
|
|
||||||
|
// respCompatInputCalls 改写多轮历史 input:function_call 的 namespace 字段重限定
|
||||||
|
// (上游不认识该字段);custom_tool_call / custom_tool_call_output 转换为上游
|
||||||
|
// 认识的 function_call(_output) 形态(上游 item 变体表不含 custom 系)。
|
||||||
|
func respCompatInputCalls(body map[string]any) {
|
||||||
|
input, _ := body["input"].([]any)
|
||||||
|
for _, it := range input {
|
||||||
|
item, _ := it.(map[string]any)
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch item["type"] {
|
||||||
|
case "function_call":
|
||||||
|
respQualifyCallField(item)
|
||||||
|
case "custom_tool_call":
|
||||||
|
item["type"] = "function_call"
|
||||||
|
item["arguments"] = respCustomCallArguments(item["input"])
|
||||||
|
delete(item, "input")
|
||||||
|
case "custom_tool_call_output":
|
||||||
|
item["type"] = "function_call_output"
|
||||||
|
item["output"] = respCustomCallOutput(item["output"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// respCustomCallArguments 把 custom_tool_call 的 input 包装为 function_call 的
|
||||||
|
// arguments JSON 串(对齐 CLIProxyAPI):input 串本身是 JSON 对象则直用,普通
|
||||||
|
// 文本包一层 {"input": 文本};对象取序列化;缺失为 {}。与响应侧
|
||||||
|
// respUnwrapCustomInput 互逆,保证多轮往返无损。
|
||||||
|
func respCustomCallArguments(input any) string {
|
||||||
|
switch v := input.(type) {
|
||||||
|
case string:
|
||||||
|
trimmed := strings.TrimSpace(v)
|
||||||
|
var probe map[string]any
|
||||||
|
if json.Unmarshal([]byte(trimmed), &probe) == nil && probe != nil {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
enc, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
return `{"input":` + string(enc) + `}`
|
||||||
|
case nil:
|
||||||
|
return "{}"
|
||||||
|
default:
|
||||||
|
enc, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
return string(enc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// respCustomCallOutput 归一 output 为字符串:非字符串时序列化保底。
|
||||||
|
func respCustomCallOutput(output any) any {
|
||||||
|
if _, ok := output.(string); ok {
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
if output == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
enc, err := json.Marshal(output)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(enc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// respCompatToolChoice 限定化对象形态的 tool_choice 及其 allowed_tools 列表
|
||||||
|
// (上游不认 tool_choice 上的 namespace 字段)。
|
||||||
|
func respCompatToolChoice(body map[string]any) {
|
||||||
|
tc, _ := body["tool_choice"].(map[string]any)
|
||||||
|
if tc == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tc["type"] == "function" {
|
||||||
|
respQualifyCallField(tc)
|
||||||
|
}
|
||||||
|
list, _ := tc["tools"].([]any)
|
||||||
|
for _, it := range list {
|
||||||
|
if sub, _ := it.(map[string]any); sub != nil && sub["type"] == "function" {
|
||||||
|
respQualifyCallField(sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// respQualifyCallField 把一个带 name/namespace 的对象改写为限定名形态。
|
||||||
|
func respQualifyCallField(m map[string]any) {
|
||||||
|
ns, _ := m["namespace"].(string)
|
||||||
|
if strings.TrimSpace(ns) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name, _ := m["name"].(string)
|
||||||
|
if q := respQualifyNsName(ns, name); q != "" {
|
||||||
|
m["name"] = q
|
||||||
|
}
|
||||||
|
delete(m, "namespace")
|
||||||
|
}
|
||||||
|
|
||||||
|
// respToolDrop 判定单个工具是否剥离。web_search 的 external_web_access 参数
|
||||||
|
// 上游 400:false 表示 OpenAI 的"仅缓存检索"降权模式,上游无对应能力,按不
|
||||||
|
// 越权原则整个工具剥离;true 等价上游默认行为,仅删键放行。tool_search(codex
|
||||||
|
// 的工具目录搜索)上游不识别,且 namespace 已全量拍平上送、搜索语义冗余,剥离。
|
||||||
|
func respToolDrop(tool map[string]any) (string, bool) {
|
||||||
|
if tool == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
switch tool["type"] {
|
||||||
|
case "tool_search":
|
||||||
|
return "tool_search", true
|
||||||
|
case "web_search":
|
||||||
|
access, has := tool["external_web_access"]
|
||||||
|
if !has {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
delete(tool, "external_web_access")
|
||||||
|
if access == false {
|
||||||
|
return "web_search(external_web_access=false)", true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// RespGuardBytes 返回请求体中 instructions 与 tools 两字段的原始字节数之和,
|
||||||
|
// 流式保险丝据此判定。上游对二者合计 >≈64.5KB 的流式请求会在推理阶段静默断流
|
||||||
|
// (纯 EOF,input 正文不计入;2026-07-16 实测仍存在),阈值由设置页 AI Tab 配置。
|
||||||
|
// 解析失败返回 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(流式升级回退用),其余字段
|
||||||
|
// 原样保留。
|
||||||
|
func RespDisableStream(body []byte) ([]byte, error) {
|
||||||
|
dec := json.NewDecoder(strings.NewReader(string(body)))
|
||||||
|
dec.UseNumber()
|
||||||
|
var m map[string]any
|
||||||
|
if err := dec.Decode(&m); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析请求体: %w", err)
|
||||||
|
}
|
||||||
|
m["stream"] = false
|
||||||
|
return json.Marshal(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RespSynthSSEEvents 把一份非流式响应合成为最小 SSE 事件序列:created →
|
||||||
|
// 每个输出项一条 output_item.done → completed。流式升级回退用,客户端拿到
|
||||||
|
// 完整事件语义但无增量;输出项与 usage 原样承载。
|
||||||
|
func RespSynthSSEEvents(payload []byte) ([][]byte, error) {
|
||||||
|
dec := json.NewDecoder(strings.NewReader(string(payload)))
|
||||||
|
dec.UseNumber()
|
||||||
|
var resp map[string]any
|
||||||
|
if err := dec.Decode(&resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析上游响应: %w", err)
|
||||||
|
}
|
||||||
|
output, _ := resp["output"].([]any)
|
||||||
|
status, hadStatus := resp["status"]
|
||||||
|
resp["status"], resp["output"] = "in_progress", []any{}
|
||||||
|
created, err := json.Marshal(map[string]any{"type": "response.created", "response": resp})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if hadStatus {
|
||||||
|
resp["status"] = status
|
||||||
|
} else {
|
||||||
|
delete(resp, "status")
|
||||||
|
}
|
||||||
|
resp["output"] = output
|
||||||
|
events := [][]byte{created}
|
||||||
|
for i, item := range output {
|
||||||
|
ev, err := json.Marshal(map[string]any{"type": "response.output_item.done", "output_index": i, "item": item})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
msgs = out
|
events = append(events, ev)
|
||||||
}
|
}
|
||||||
return msgs, nil
|
completed, err := json.Marshal(map[string]any{"type": "response.completed", "response": resp})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return append(events, completed), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// respItemToMessages 追加一个 input item;连续 function_call 合并进同一 assistant 消息。
|
// RespRestoreToolCalls 非流式响应还原:output 数组中命中 NsRefs 的 function_call
|
||||||
func respItemToMessages(it aiwire.RespItem, msgs []aiwire.ChatMessage) ([]aiwire.ChatMessage, error) {
|
// 把限定名还原为短名并补回 namespace 字段(codex 以该双字段路由);命中
|
||||||
switch it.Type {
|
// CustomNames 的回转 custom_tool_call(codex 期望 input 字段形态)。无需还原或
|
||||||
case "", "message":
|
// 解析失败时返回原字节,不破坏直通。
|
||||||
if it.Content.HasUnsupported() {
|
func RespRestoreToolCalls(payload []byte, compat RespCompat) []byte {
|
||||||
return nil, ErrAiUnsupportedBlock
|
if !compat.NeedRestore() {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(strings.NewReader(string(payload)))
|
||||||
|
dec.UseNumber()
|
||||||
|
var body map[string]any
|
||||||
|
if dec.Decode(&body) != nil {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
if !respRestoreOutput(body["output"], compat) {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
out, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// RespRestoreToolCallsEvent 流式单事件还原:处理事件顶层 item(output_item.*)
|
||||||
|
// 与 response.output(created/completed 等快照)。返回还原后数据与是否改动;
|
||||||
|
// 未改动时调用方转发原始行,保持字节级直通。
|
||||||
|
func RespRestoreToolCallsEvent(data []byte, compat RespCompat) ([]byte, bool) {
|
||||||
|
if !compat.NeedRestore() {
|
||||||
|
return data, false
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(strings.NewReader(string(data)))
|
||||||
|
dec.UseNumber()
|
||||||
|
var ev map[string]any
|
||||||
|
if dec.Decode(&ev) != nil {
|
||||||
|
return data, false
|
||||||
|
}
|
||||||
|
changed := respRestoreCallItem(ev["item"], compat)
|
||||||
|
if resp, _ := ev["response"].(map[string]any); resp != nil {
|
||||||
|
changed = respRestoreOutput(resp["output"], compat) || changed
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return data, false
|
||||||
|
}
|
||||||
|
out, err := json.Marshal(ev)
|
||||||
|
if err != nil {
|
||||||
|
return data, false
|
||||||
|
}
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// respRestoreOutput 还原一个 output 数组,返回是否有改动。
|
||||||
|
func respRestoreOutput(v any, compat RespCompat) bool {
|
||||||
|
items, _ := v.([]any)
|
||||||
|
changed := false
|
||||||
|
for _, it := range items {
|
||||||
|
if respRestoreCallItem(it, compat) {
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
role := respRole(it.Role)
|
|
||||||
return append(msgs, aiwire.ChatMessage{Role: role, Content: respContentToIR(it.Content)}), nil
|
|
||||||
case "function_call":
|
|
||||||
call := aiwire.ToolCall{ID: it.CallID, Type: "function",
|
|
||||||
Function: aiwire.FunctionCall{Name: it.Name, Arguments: it.Arguments}}
|
|
||||||
if n := len(msgs); n > 0 && msgs[n-1].Role == "assistant" && len(msgs[n-1].ToolCalls) > 0 {
|
|
||||||
msgs[n-1].ToolCalls = append(msgs[n-1].ToolCalls, call)
|
|
||||||
return msgs, nil
|
|
||||||
}
|
|
||||||
return append(msgs, aiwire.ChatMessage{Role: "assistant", ToolCalls: []aiwire.ToolCall{call}}), nil
|
|
||||||
case "function_call_output":
|
|
||||||
return append(msgs, aiwire.ChatMessage{Role: "tool", ToolCallID: it.CallID,
|
|
||||||
Content: aiwire.NewTextContent(it.OutputText())}), nil
|
|
||||||
case "reasoning":
|
|
||||||
return msgs, nil // 推理块不回灌上游
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("不支持的 input item 类型 %q", it.Type)
|
|
||||||
}
|
}
|
||||||
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
// respRole 归一 item 角色;developer 视为 system,缺省 user。
|
// respRestoreCallItem 还原单个 function_call 项,返回是否改动。namespace 还原与
|
||||||
func respRole(role string) string {
|
// custom 回转互斥:custom 工具不参与 namespace 拍平。
|
||||||
switch role {
|
func respRestoreCallItem(v any, compat RespCompat) bool {
|
||||||
case "system", "developer":
|
item, _ := v.(map[string]any)
|
||||||
return "system"
|
if item == nil || item["type"] != "function_call" {
|
||||||
case "assistant":
|
return false
|
||||||
return "assistant"
|
|
||||||
default:
|
|
||||||
return "user"
|
|
||||||
}
|
}
|
||||||
|
name, _ := item["name"].(string)
|
||||||
|
if ref, ok := compat.NsRefs[name]; ok {
|
||||||
|
item["name"] = ref.Name
|
||||||
|
item["namespace"] = ref.Namespace
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, ok := compat.CustomNames[name]; ok {
|
||||||
|
item["type"] = "custom_tool_call"
|
||||||
|
item["input"] = respUnwrapCustomInput(item["arguments"])
|
||||||
|
delete(item, "arguments")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// respContentToIR 把 Responses 内容转 IR;纯文本拍平,含图片时保留部件顺序。
|
// respUnwrapCustomInput 解包 arguments:形如 {"input": 串} 取其 input(非串取该
|
||||||
func respContentToIR(c aiwire.RespContent) aiwire.Content {
|
// 值原文),其余情况整串返回。与请求侧 respCustomCallArguments 互逆。
|
||||||
var parts []aiwire.ContentPart
|
func respUnwrapCustomInput(arguments any) string {
|
||||||
hasImage := false
|
s, _ := arguments.(string)
|
||||||
for _, p := range c.Parts {
|
var probe map[string]json.RawMessage
|
||||||
switch p.Type {
|
if json.Unmarshal([]byte(s), &probe) == nil {
|
||||||
case "input_image":
|
if raw, ok := probe["input"]; ok {
|
||||||
parts = append(parts, aiwire.ContentPart{Type: "image_url",
|
var str string
|
||||||
ImageURL: &aiwire.ImageURL{URL: p.ImageURL, Detail: p.Detail}})
|
if json.Unmarshal(raw, &str) == nil {
|
||||||
hasImage = true
|
return str
|
||||||
default:
|
|
||||||
if p.Text != "" {
|
|
||||||
parts = append(parts, aiwire.ContentPart{Type: "text", Text: p.Text})
|
|
||||||
}
|
}
|
||||||
|
return string(raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !c.IsArray || !hasImage {
|
return s
|
||||||
return aiwire.NewTextContent(c.JoinText())
|
|
||||||
}
|
|
||||||
return aiwire.NewPartsContent(parts)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// respTools 把扁平工具定义还原为嵌套 Chat 形态;strict 无 OCI 对应,忽略。
|
// RespPassthroughUsage 从直通响应提取用量;缺失时返回 nil(日志记零)。
|
||||||
func respTools(tools []aiwire.RespTool) []aiwire.Tool {
|
func RespPassthroughUsage(payload []byte) *aiwire.Usage {
|
||||||
if len(tools) == 0 {
|
var root struct {
|
||||||
|
Usage *aiwire.RespUsage `json:"usage"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(payload, &root) != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out := make([]aiwire.Tool, 0, len(tools))
|
return usageFromResp(root.Usage)
|
||||||
for _, t := range tools {
|
|
||||||
out = append(out, aiwire.Tool{Type: "function",
|
|
||||||
Function: aiwire.FunctionDef{Name: t.Name, Description: t.Description, Parameters: t.Parameters}})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// respToolChoice 转换 tool_choice:字符串直通;{type:function,name} 转嵌套;
|
// usageFromResp 换算 Responses usage 为 OpenAI 口径(缓存命中透传);nil 原样返回。
|
||||||
// allowed_tools 等对象形态降级 auto。
|
func usageFromResp(u *aiwire.RespUsage) *aiwire.Usage {
|
||||||
func respToolChoice(raw json.RawMessage) json.RawMessage {
|
|
||||||
if len(raw) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
t := strings.TrimSpace(string(raw))
|
|
||||||
if strings.HasPrefix(t, "\"") {
|
|
||||||
return raw
|
|
||||||
}
|
|
||||||
var obj struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &obj); err != nil || obj.Type != "function" || obj.Name == "" {
|
|
||||||
return json.RawMessage(`"auto"`)
|
|
||||||
}
|
|
||||||
out, _ := json.Marshal(map[string]any{"type": "function", "function": map[string]string{"name": obj.Name}})
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// respFormat 把 text.format 转成 response_format;text 形态无需显式指定。
|
|
||||||
func respFormat(text *aiwire.RespText) *aiwire.ResponseFormat {
|
|
||||||
if text == nil || text.Format == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
switch text.Format.Type {
|
|
||||||
case "json_object":
|
|
||||||
return &aiwire.ResponseFormat{Type: "json_object"}
|
|
||||||
case "json_schema":
|
|
||||||
spec, _ := json.Marshal(map[string]any{
|
|
||||||
"name": text.Format.Name, "schema": json.RawMessage(text.Format.Schema), "strict": text.Format.Strict,
|
|
||||||
})
|
|
||||||
return &aiwire.ResponseFormat{Type: "json_schema", JSONSchema: spec}
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// IRRespToResponses 把 IR 非流式响应装配为 Response 对象。
|
|
||||||
func IRRespToResponses(resp *aiwire.ChatResponse, id string, created int64) aiwire.Response {
|
|
||||||
out := aiwire.Response{ID: id, Object: "response", CreatedAt: created,
|
|
||||||
Status: "completed", Model: resp.Model, Output: []aiwire.RespOutItem{}, Store: false}
|
|
||||||
if len(resp.Choices) == 0 {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
choice := resp.Choices[0]
|
|
||||||
if text := choice.Message.Content.JoinText(); text != "" {
|
|
||||||
out.Output = append(out.Output, respMessageItem(id, 0, text))
|
|
||||||
}
|
|
||||||
for i, tc := range choice.Message.ToolCalls {
|
|
||||||
out.Output = append(out.Output, aiwire.RespOutItem{Type: "function_call",
|
|
||||||
ID: respItemID(id, "fc", len(out.Output)+i), Status: "completed",
|
|
||||||
CallID: tc.ID, Name: tc.Function.Name, Arguments: respArgs(tc.Function.Arguments)})
|
|
||||||
}
|
|
||||||
if choice.FinishReason == "length" {
|
|
||||||
out.Status = "incomplete"
|
|
||||||
out.IncompleteDetails = &aiwire.RespIncomplete{Reason: "max_output_tokens"}
|
|
||||||
}
|
|
||||||
out.Usage = respUsage(resp.Usage)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func respMessageItem(respID string, idx int, text string) aiwire.RespOutItem {
|
|
||||||
return aiwire.RespOutItem{Type: "message", ID: respItemID(respID, "msg", idx),
|
|
||||||
Status: "completed", Role: "assistant",
|
|
||||||
Content: []aiwire.RespOutPart{{Type: "output_text", Text: text, Annotations: []any{}}}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// respItemID 从响应 ID 派生确定性 item ID。
|
|
||||||
func respItemID(respID, kind string, idx int) string {
|
|
||||||
return fmt.Sprintf("%s_%s_%d", kind, strings.TrimPrefix(respID, "resp_"), idx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// respArgs 保证 arguments 是合法 JSON 字符串(空实参回退 {})。
|
|
||||||
func respArgs(args string) string {
|
|
||||||
if strings.TrimSpace(args) == "" {
|
|
||||||
return "{}"
|
|
||||||
}
|
|
||||||
return args
|
|
||||||
}
|
|
||||||
|
|
||||||
// respUsage 把 IR 用量改写为 Responses 命名口径。
|
|
||||||
func respUsage(u *aiwire.Usage) *aiwire.RespUsage {
|
|
||||||
if u == nil {
|
if u == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &aiwire.RespUsage{InputTokens: u.PromptTokens, OutputTokens: u.CompletionTokens,
|
usage := &aiwire.Usage{PromptTokens: u.InputTokens,
|
||||||
TotalTokens: u.TotalTokens,
|
CompletionTokens: u.OutputTokens, TotalTokens: u.TotalTokens}
|
||||||
InputTokensDetails: aiwire.RespInDetails{CachedTokens: u.CachedTokens()}}
|
if cached := u.InputTokensDetails.CachedTokens; cached > 0 {
|
||||||
|
usage.PromptTokensDetails = &aiwire.PromptTokensDetails{CachedTokens: cached}
|
||||||
|
}
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
|
||||||
|
// RespStreamCompletedUsage 从一行 SSE data JSON 中提取 response.completed 事件的
|
||||||
|
// usage;非 completed 事件或解析失败返回 nil。流式直通逐行喂入,最后一次非 nil 生效。
|
||||||
|
func RespStreamCompletedUsage(data []byte) *aiwire.Usage {
|
||||||
|
var ev struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Response json.RawMessage `json:"response"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(data, &ev) != nil || ev.Type != "response.completed" || len(ev.Response) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return RespPassthroughUsage(ev.Response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RespStreamErrorMsg 从一行 SSE data JSON 中提取 error / response.failed 事件的
|
||||||
|
// 错误消息;非错误事件返回空串。流式直通据此把上游错误写入调用日志。
|
||||||
|
func RespStreamErrorMsg(data []byte) string {
|
||||||
|
var ev respStreamEvent
|
||||||
|
if json.Unmarshal(data, &ev) != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch ev.Type {
|
||||||
|
case "error":
|
||||||
|
if ev.Message != "" {
|
||||||
|
return ev.Message
|
||||||
|
}
|
||||||
|
return "上游返回错误事件 error"
|
||||||
|
case "response.failed":
|
||||||
|
if m := respErrorMsg(ev.Response); m != "" {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return "上游返回错误事件 response.failed"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"oci-portal/internal/aiwire"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RespEvent 是一条 Responses SSE 语义事件(event 名 + data 载荷)。
|
|
||||||
type RespEvent struct {
|
|
||||||
Event string
|
|
||||||
Data map[string]any
|
|
||||||
}
|
|
||||||
|
|
||||||
// RespStream 把 IR chunk 流聚合为 Responses 语义事件序列。
|
|
||||||
// 与 AnthStream 同构,但终态事件须携带完整 Response 快照,故全程缓冲文本与实参。
|
|
||||||
type RespStream struct {
|
|
||||||
id string
|
|
||||||
model string
|
|
||||||
created int64
|
|
||||||
seq int
|
|
||||||
started bool
|
|
||||||
items []aiwire.RespOutItem
|
|
||||||
msgOpen bool
|
|
||||||
text strings.Builder
|
|
||||||
tool aiwire.RespOutItem
|
|
||||||
toolIdx int
|
|
||||||
tOpen bool
|
|
||||||
args strings.Builder
|
|
||||||
finish string
|
|
||||||
usage *aiwire.Usage
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRespStream 构造状态机;id 形如 resp_*,created 为响应时间戳。
|
|
||||||
func NewRespStream(id, model string, created int64) *RespStream {
|
|
||||||
return &RespStream{id: id, model: model, created: created}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Feed 消化一个上游 chunk,返回应立即下发的事件。
|
|
||||||
func (s *RespStream) Feed(chunk aiwire.ChatChunk) []RespEvent {
|
|
||||||
var evs []RespEvent
|
|
||||||
if !s.started {
|
|
||||||
s.started = true
|
|
||||||
evs = append(evs, s.respEvent("response.created", "in_progress"),
|
|
||||||
s.respEvent("response.in_progress", "in_progress"))
|
|
||||||
}
|
|
||||||
if chunk.Usage != nil {
|
|
||||||
s.usage = chunk.Usage
|
|
||||||
}
|
|
||||||
if len(chunk.Choices) == 0 {
|
|
||||||
return evs
|
|
||||||
}
|
|
||||||
choice := chunk.Choices[0]
|
|
||||||
if choice.Delta.Content != "" {
|
|
||||||
evs = append(evs, s.feedText(choice.Delta.Content)...)
|
|
||||||
}
|
|
||||||
for _, tc := range choice.Delta.ToolCalls {
|
|
||||||
evs = append(evs, s.feedTool(tc)...)
|
|
||||||
}
|
|
||||||
if choice.FinishReason != nil {
|
|
||||||
s.finish = *choice.FinishReason
|
|
||||||
}
|
|
||||||
return evs
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finish 关闭未闭合的块并产出终态事件(带完整 Response 快照)。
|
|
||||||
func (s *RespStream) Finish() []RespEvent {
|
|
||||||
var evs []RespEvent
|
|
||||||
if !s.started {
|
|
||||||
s.started = true
|
|
||||||
evs = append(evs, s.respEvent("response.created", "in_progress"))
|
|
||||||
}
|
|
||||||
evs = append(evs, s.closeMsg()...)
|
|
||||||
evs = append(evs, s.closeTool()...)
|
|
||||||
status := "completed"
|
|
||||||
if s.finish == "length" {
|
|
||||||
status = "incomplete"
|
|
||||||
}
|
|
||||||
return append(evs, s.respEvent("response."+status, status))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage 返回聚合到的用量(可能为 nil),供调用日志。
|
|
||||||
func (s *RespStream) Usage() *aiwire.Usage { return s.usage }
|
|
||||||
|
|
||||||
// ev 构造带自增 sequence_number 的事件。
|
|
||||||
func (s *RespStream) ev(typ string, kv map[string]any) RespEvent {
|
|
||||||
s.seq++
|
|
||||||
data := map[string]any{"type": typ, "sequence_number": s.seq}
|
|
||||||
for k, v := range kv {
|
|
||||||
data[k] = v
|
|
||||||
}
|
|
||||||
return RespEvent{Event: typ, Data: data}
|
|
||||||
}
|
|
||||||
|
|
||||||
// respEvent 构造携带 Response 快照的生命周期事件。
|
|
||||||
func (s *RespStream) respEvent(typ, status string) RespEvent {
|
|
||||||
return s.ev(typ, map[string]any{"response": s.snapshot(status)})
|
|
||||||
}
|
|
||||||
|
|
||||||
// snapshot 组装当前累计状态的 Response 对象。
|
|
||||||
func (s *RespStream) snapshot(status string) aiwire.Response {
|
|
||||||
resp := aiwire.Response{ID: s.id, Object: "response", CreatedAt: s.created,
|
|
||||||
Status: status, Model: s.model, Store: false,
|
|
||||||
Output: append([]aiwire.RespOutItem{}, s.items...)}
|
|
||||||
resp.Usage = respUsage(s.usage)
|
|
||||||
if status == "incomplete" {
|
|
||||||
resp.IncompleteDetails = &aiwire.RespIncomplete{Reason: "max_output_tokens"}
|
|
||||||
}
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
// feedText 处理文本增量:必要时开 message item 与 content part。
|
|
||||||
func (s *RespStream) feedText(delta string) []RespEvent {
|
|
||||||
evs := s.closeTool()
|
|
||||||
if !s.msgOpen {
|
|
||||||
s.msgOpen = true
|
|
||||||
s.text.Reset()
|
|
||||||
item := aiwire.RespOutItem{Type: "message", ID: s.itemID("msg"), Status: "in_progress",
|
|
||||||
Role: "assistant", Content: []aiwire.RespOutPart{}}
|
|
||||||
evs = append(evs, s.ev("response.output_item.added", map[string]any{
|
|
||||||
"output_index": len(s.items), "item": item}))
|
|
||||||
evs = append(evs, s.ev("response.content_part.added", map[string]any{
|
|
||||||
"item_id": item.ID, "output_index": len(s.items), "content_index": 0,
|
|
||||||
"part": aiwire.RespOutPart{Type: "output_text", Text: "", Annotations: []any{}}}))
|
|
||||||
}
|
|
||||||
s.text.WriteString(delta)
|
|
||||||
return append(evs, s.ev("response.output_text.delta", map[string]any{
|
|
||||||
"item_id": s.itemID("msg"), "output_index": len(s.items), "content_index": 0, "delta": delta}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// closeMsg 闭合当前 message item(text done → part done → item done)。
|
|
||||||
func (s *RespStream) closeMsg() []RespEvent {
|
|
||||||
if !s.msgOpen {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
s.msgOpen = false
|
|
||||||
id, idx, full := s.itemID("msg"), len(s.items), s.text.String()
|
|
||||||
part := aiwire.RespOutPart{Type: "output_text", Text: full, Annotations: []any{}}
|
|
||||||
item := aiwire.RespOutItem{Type: "message", ID: id, Status: "completed",
|
|
||||||
Role: "assistant", Content: []aiwire.RespOutPart{part}}
|
|
||||||
evs := []RespEvent{
|
|
||||||
s.ev("response.output_text.done", map[string]any{"item_id": id, "output_index": idx, "content_index": 0, "text": full}),
|
|
||||||
s.ev("response.content_part.done", map[string]any{"item_id": id, "output_index": idx, "content_index": 0, "part": part}),
|
|
||||||
s.ev("response.output_item.done", map[string]any{"output_index": idx, "item": item}),
|
|
||||||
}
|
|
||||||
s.items = append(s.items, item)
|
|
||||||
return evs
|
|
||||||
}
|
|
||||||
|
|
||||||
// feedTool 处理工具调用增量:新 Index 先闭合旧调用再开新 item。
|
|
||||||
func (s *RespStream) feedTool(tc aiwire.ToolCallDelta) []RespEvent {
|
|
||||||
evs := s.closeMsg()
|
|
||||||
if s.tOpen && tc.Index != s.toolIdx {
|
|
||||||
evs = append(evs, s.closeTool()...)
|
|
||||||
}
|
|
||||||
if !s.tOpen {
|
|
||||||
s.tOpen, s.toolIdx = true, tc.Index
|
|
||||||
s.args.Reset()
|
|
||||||
s.tool = aiwire.RespOutItem{Type: "function_call", ID: s.itemID("fc"), Status: "in_progress",
|
|
||||||
CallID: tc.ID, Name: tc.Function.Name}
|
|
||||||
evs = append(evs, s.ev("response.output_item.added", map[string]any{
|
|
||||||
"output_index": len(s.items), "item": s.tool}))
|
|
||||||
}
|
|
||||||
if tc.ID != "" && s.tool.CallID == "" {
|
|
||||||
s.tool.CallID = tc.ID
|
|
||||||
}
|
|
||||||
if tc.Function.Name != "" && s.tool.Name == "" {
|
|
||||||
s.tool.Name = tc.Function.Name
|
|
||||||
}
|
|
||||||
if tc.Function.Arguments == "" {
|
|
||||||
return evs
|
|
||||||
}
|
|
||||||
s.args.WriteString(tc.Function.Arguments)
|
|
||||||
return append(evs, s.ev("response.function_call_arguments.delta", map[string]any{
|
|
||||||
"item_id": s.tool.ID, "output_index": len(s.items), "delta": tc.Function.Arguments}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// closeTool 闭合当前 function_call item(arguments done → item done)。
|
|
||||||
func (s *RespStream) closeTool() []RespEvent {
|
|
||||||
if !s.tOpen {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
s.tOpen = false
|
|
||||||
s.tool.Arguments = respArgs(s.args.String())
|
|
||||||
s.tool.Status = "completed"
|
|
||||||
idx := len(s.items)
|
|
||||||
evs := []RespEvent{
|
|
||||||
s.ev("response.function_call_arguments.done", map[string]any{
|
|
||||||
"item_id": s.tool.ID, "output_index": idx, "arguments": s.tool.Arguments}),
|
|
||||||
s.ev("response.output_item.done", map[string]any{"output_index": idx, "item": s.tool}),
|
|
||||||
}
|
|
||||||
s.items = append(s.items, s.tool)
|
|
||||||
return evs
|
|
||||||
}
|
|
||||||
|
|
||||||
// itemID 按当前 output 序号派生确定性 item ID。
|
|
||||||
func (s *RespStream) itemID(kind string) string {
|
|
||||||
return respItemID(s.id, kind, len(s.items))
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,8 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -17,47 +19,6 @@ func respReq(t *testing.T, raw string) aiwire.RespRequest {
|
|||||||
return req
|
return req
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResponsesToIR(t *testing.T) {
|
|
||||||
req := respReq(t, `{
|
|
||||||
"model": "meta.llama-3.3-70b-instruct",
|
|
||||||
"instructions": "你是助手",
|
|
||||||
"max_output_tokens": 128,
|
|
||||||
"input": [
|
|
||||||
{"role": "user", "content": "查天气"},
|
|
||||||
{"type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": "{\"city\":\"上海\"}"},
|
|
||||||
{"type": "function_call_output", "call_id": "call_1", "output": "{\"temp\":31}"},
|
|
||||||
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "31 度"}]}
|
|
||||||
],
|
|
||||||
"tools": [{"type": "function", "name": "get_weather", "description": "查天气", "parameters": {"type": "object"}, "strict": true}],
|
|
||||||
"tool_choice": {"type": "function", "name": "get_weather"},
|
|
||||||
"text": {"format": {"type": "json_schema", "name": "out", "schema": {"type": "object"}}}
|
|
||||||
}`)
|
|
||||||
ir, err := ResponsesToIR(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ResponsesToIR: %v", err)
|
|
||||||
}
|
|
||||||
roles := make([]string, 0, len(ir.Messages))
|
|
||||||
for _, m := range ir.Messages {
|
|
||||||
roles = append(roles, m.Role)
|
|
||||||
}
|
|
||||||
want := []string{"system", "user", "assistant", "tool", "assistant"}
|
|
||||||
if strings.Join(roles, ",") != strings.Join(want, ",") {
|
|
||||||
t.Errorf("roles = %v, want %v", roles, want)
|
|
||||||
}
|
|
||||||
if ir.Messages[2].ToolCalls[0].ID != "call_1" || ir.Messages[3].ToolCallID != "call_1" {
|
|
||||||
t.Errorf("tool call 链接错误: %+v", ir.Messages)
|
|
||||||
}
|
|
||||||
if deref(ir.MaxTokens) != 128 || len(ir.Tools) != 1 || ir.Tools[0].Function.Name != "get_weather" {
|
|
||||||
t.Errorf("参数映射错误: max=%v tools=%+v", ir.MaxTokens, ir.Tools)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(ir.ToolChoice), `"function"`) || !strings.Contains(string(ir.ToolChoice), "get_weather") {
|
|
||||||
t.Errorf("tool_choice = %s", ir.ToolChoice)
|
|
||||||
}
|
|
||||||
if ir.ResponseFormat == nil || ir.ResponseFormat.Type != "json_schema" {
|
|
||||||
t.Errorf("response_format = %+v", ir.ResponseFormat)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func deref(p *int) int {
|
func deref(p *int) int {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return 0
|
return 0
|
||||||
@@ -65,112 +26,534 @@ func deref(p *int) int {
|
|||||||
return *p
|
return *p
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResponsesToIRRejects(t *testing.T) {
|
func TestRespPassthroughValidate(t *testing.T) {
|
||||||
cases := []struct{ name, raw string }{
|
prev := "resp_1"
|
||||||
{"previous_response_id", `{"model":"m","input":"hi","previous_response_id":"resp_x"}`},
|
bg := true
|
||||||
{"conversation", `{"model":"m","input":"hi","conversation":{"id":"conv_1"}}`},
|
tests := []struct {
|
||||||
{"background", `{"model":"m","input":"hi","background":true}`},
|
name string
|
||||||
{"builtin tool", `{"model":"m","input":"hi","tools":[{"type":"web_search"}]}`},
|
req aiwire.RespRequest
|
||||||
{"file_id image", `{"model":"m","input":[{"role":"user","content":[{"type":"input_image","file_id":"file_1"}]}]}`},
|
wantErr bool
|
||||||
{"unknown item", `{"model":"m","input":[{"type":"item_reference","id":"x"}]}`},
|
}{
|
||||||
|
{"合法", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "web_search"}}}, false},
|
||||||
|
{"混用function", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "web_search"}, {Type: "function", Name: "f"}}}, false},
|
||||||
|
{"缺model", aiwire.RespRequest{Tools: []aiwire.RespTool{{Type: "web_search"}}}, true},
|
||||||
|
{"服务端工具流式放行", aiwire.RespRequest{Model: "m", Stream: true, Tools: []aiwire.RespTool{{Type: "web_search"}}}, false},
|
||||||
|
{"无工具流式放行", aiwire.RespRequest{Model: "m", Stream: true}, false},
|
||||||
|
{"function工具流式放行", aiwire.RespRequest{Model: "m", Stream: true, Tools: []aiwire.RespTool{{Type: "function", Name: "f"}}}, false},
|
||||||
|
{"code_interpreter放行", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "code_interpreter"}}}, false},
|
||||||
|
{"mcp流式放行", aiwire.RespRequest{Model: "m", Stream: true, Tools: []aiwire.RespTool{{Type: "mcp"}}}, false},
|
||||||
|
{"有状态拒绝", aiwire.RespRequest{Model: "m", PreviousResponseID: prev}, true},
|
||||||
|
{"background拒绝", aiwire.RespRequest{Model: "m", Background: &bg}, true},
|
||||||
|
{"未知工具拒绝", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "web_search"}, {Type: "file_search"}}}, true},
|
||||||
|
{"namespace放行", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "namespace", Name: "multi_agent_v1"}}}, false},
|
||||||
|
{"custom放行", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "custom", Name: "run_script"}}}, false},
|
||||||
|
{"tool_search放行", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "tool_search"}}}, false},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, test := range tests {
|
||||||
if _, err := ResponsesToIR(respReq(t, c.raw)); err == nil {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
t.Errorf("%s 应被拒绝", c.name)
|
err := RespPassthroughValidate(test.req)
|
||||||
}
|
if (err != nil) != test.wantErr {
|
||||||
|
t.Fatalf("err = %v, wantErr %v", err, test.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
// input 字符串形态 + store/reasoning 忽略项不报错
|
}
|
||||||
req := respReq(t, `{"model":"m","input":"你好","store":false,"reasoning":{"effort":"low"},"parallel_tool_calls":true}`)
|
|
||||||
ir, err := ResponsesToIR(req)
|
func TestRespPassthroughBody(t *testing.T) {
|
||||||
if err != nil || len(ir.Messages) != 1 || ir.Messages[0].Role != "user" || ir.ReasoningEffort != "low" {
|
raw := []byte(`{"model":"m","input":"hi","stream":true,"store":true,"max_output_tokens":128,"custom_field":{"a":1.5}}`)
|
||||||
t.Errorf("字符串 input = %+v, %v", ir.Messages, err)
|
out, compat, err := RespPassthroughBody(raw)
|
||||||
}
|
|
||||||
// input_image(url 形态)放行并保留图文顺序
|
|
||||||
req2 := respReq(t, `{"model":"m","input":[{"role":"user","content":[{"type":"input_text","text":"看图"},{"type":"input_image","image_url":"https://x/1.png","detail":"low"}]}]}`)
|
|
||||||
ir2, err := ResponsesToIR(req2)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("input_image 应放行: %v", err)
|
t.Fatalf("RespPassthroughBody: %v", err)
|
||||||
}
|
}
|
||||||
parts := ir2.Messages[0].Content.Parts
|
if len(compat.Dropped) != 0 || len(compat.Flattened) != 0 || len(compat.NsRefs) != 0 {
|
||||||
if len(parts) != 2 || parts[1].Type != "image_url" || parts[1].ImageURL == nil || parts[1].ImageURL.URL != "https://x/1.png" {
|
t.Errorf("无 codex 工具不应有兼容改写: %+v", compat)
|
||||||
t.Errorf("parts = %+v", parts)
|
}
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(out, &body); err != nil {
|
||||||
|
t.Fatalf("unmarshal out: %v", err)
|
||||||
|
}
|
||||||
|
if body["store"] != false {
|
||||||
|
t.Errorf("store 应强制 false, got %v", body["store"])
|
||||||
|
}
|
||||||
|
if body["stream"] != true {
|
||||||
|
t.Errorf("stream 应原样保留, got %v", body["stream"])
|
||||||
|
}
|
||||||
|
if string(out) == "" || !strings.Contains(string(out), `"max_output_tokens":128`) {
|
||||||
|
t.Errorf("数值字段应保真: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(out), `"custom_field"`) {
|
||||||
|
t.Errorf("未知字段应保留: %s", out)
|
||||||
|
}
|
||||||
|
if _, _, err := RespPassthroughBody([]byte("not-json")); err == nil {
|
||||||
|
t.Error("非法 JSON 应报错")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIRRespToResponses(t *testing.T) {
|
// TestRespQualifyNsName 断言限定名生成规则与 CLIProxyAPI 对齐。
|
||||||
resp := &aiwire.ChatResponse{Model: "m", Choices: []aiwire.Choice{{
|
func TestRespQualifyNsName(t *testing.T) {
|
||||||
Message: aiwire.ChatMessage{Role: "assistant", Content: aiwire.NewTextContent("你好"),
|
tests := []struct{ name, ns, tool, want string }{
|
||||||
ToolCalls: []aiwire.ToolCall{{ID: "call_9", Type: "function",
|
{"常规拼接", "multi_agent_v1", "spawn_agent", "multi_agent_v1__spawn_agent"},
|
||||||
Function: aiwire.FunctionCall{Name: "f", Arguments: ""}}}},
|
{"mcp子工具不加前缀", "mcp__sites", "mcp__sites__create_site", "mcp__sites__create_site"},
|
||||||
FinishReason: "length",
|
{"ns以双下划线结尾", "codex_app__", "update", "codex_app__update"},
|
||||||
}}, Usage: &aiwire.Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15}}
|
{"已带前缀不重复", "ns1", "ns1__tool", "ns1__tool"},
|
||||||
out := IRRespToResponses(resp, "resp_abc", 1751966400)
|
{"空ns原样", "", "tool", "tool"},
|
||||||
if out.Status != "incomplete" || out.IncompleteDetails == nil || out.IncompleteDetails.Reason != "max_output_tokens" {
|
{"空名返回空", "ns1", "", ""},
|
||||||
t.Errorf("length 应映射 incomplete: %+v", out)
|
|
||||||
}
|
}
|
||||||
if len(out.Output) != 2 || out.Output[0].Type != "message" || out.Output[1].Type != "function_call" {
|
for _, test := range tests {
|
||||||
t.Fatalf("output = %+v", out.Output)
|
t.Run(test.name, func(t *testing.T) {
|
||||||
}
|
if got := respQualifyNsName(test.ns, test.tool); got != test.want {
|
||||||
if out.Output[0].Content[0].Text != "你好" || out.Output[1].CallID != "call_9" || out.Output[1].Arguments != "{}" {
|
t.Fatalf("respQualifyNsName(%q,%q) = %q, want %q", test.ns, test.tool, got, test.want)
|
||||||
t.Errorf("item 装配错误: %+v", out.Output)
|
}
|
||||||
}
|
})
|
||||||
if out.Usage.InputTokens != 10 || out.Usage.OutputTokens != 5 || out.Store {
|
|
||||||
t.Errorf("usage/store 错误: %+v", out)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func chunkText(text string) aiwire.ChatChunk {
|
// TestRespPassthroughBodyCompatTools 断言 codex 工具兼容改写:namespace 拍平为
|
||||||
return aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{Content: text}}}}
|
// 限定名 function 并建映射,web_search 降权剥离,剔空连删 tool_choice。
|
||||||
|
func TestRespPassthroughBodyCompatTools(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
wantFlattened []string
|
||||||
|
wantDropped []string
|
||||||
|
wantRefs map[string]RespNsRef
|
||||||
|
wantContains []string
|
||||||
|
wantAbsent []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"namespace拍平保留function",
|
||||||
|
`{"model":"m","tools":[{"type":"function","name":"exec"},{"type":"namespace","name":"multi_agent_v1","tools":[{"type":"function","name":"spawn_agent","parameters":{"type":"object"}}]}]}`,
|
||||||
|
[]string{"namespace:multi_agent_v1"},
|
||||||
|
nil,
|
||||||
|
map[string]RespNsRef{"multi_agent_v1__spawn_agent": {Namespace: "multi_agent_v1", Name: "spawn_agent"}},
|
||||||
|
[]string{`"name":"multi_agent_v1__spawn_agent"`, `"name":"exec"`},
|
||||||
|
[]string{`"type":"namespace"`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mcp子工具保名并补parameters",
|
||||||
|
`{"model":"m","tools":[{"type":"namespace","name":"mcp__sites","tools":[{"type":"function","name":"mcp__sites__create"}]}]}`,
|
||||||
|
[]string{"namespace:mcp__sites"},
|
||||||
|
nil,
|
||||||
|
map[string]RespNsRef{"mcp__sites__create": {Namespace: "mcp__sites", Name: "mcp__sites__create"}},
|
||||||
|
[]string{`"name":"mcp__sites__create"`, `"parameters":{"properties":{},"type":"object"}`},
|
||||||
|
[]string{`"type":"namespace"`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"非function子工具剥离",
|
||||||
|
`{"model":"m","tools":[{"type":"namespace","name":"ns1","tools":[{"type":"custom","name":"patch"},{"type":"function","name":"run"}]}]}`,
|
||||||
|
[]string{"namespace:ns1"},
|
||||||
|
[]string{"ns1.patch(type=custom)"},
|
||||||
|
map[string]RespNsRef{"ns1__run": {Namespace: "ns1", Name: "run"}},
|
||||||
|
[]string{`"name":"ns1__run"`},
|
||||||
|
[]string{"patch"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"剔空连删tool_choice",
|
||||||
|
`{"model":"m","tool_choice":"auto","parallel_tool_calls":false,"tools":[{"type":"web_search","external_web_access":false}]}`,
|
||||||
|
nil,
|
||||||
|
[]string{"web_search(external_web_access=false)"},
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
[]string{`"tools"`, `"tool_choice"`, `"parallel_tool_calls"`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"web_search全访问仅删键",
|
||||||
|
`{"model":"m","tools":[{"type":"web_search","external_web_access":true}]}`,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
[]string{`"tools":[{"type":"web_search"}]`},
|
||||||
|
[]string{"external_web_access"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
out, compat, err := RespPassthroughBody([]byte(test.raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RespPassthroughBody: %v", err)
|
||||||
|
}
|
||||||
|
if !slices.Equal(compat.Flattened, test.wantFlattened) {
|
||||||
|
t.Errorf("Flattened = %v, want %v", compat.Flattened, test.wantFlattened)
|
||||||
|
}
|
||||||
|
if !slices.Equal(compat.Dropped, test.wantDropped) {
|
||||||
|
t.Errorf("Dropped = %v, want %v", compat.Dropped, test.wantDropped)
|
||||||
|
}
|
||||||
|
for q, ref := range test.wantRefs {
|
||||||
|
if compat.NsRefs[q] != ref {
|
||||||
|
t.Errorf("NsRefs[%s] = %+v, want %+v", q, compat.NsRefs[q], ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range test.wantContains {
|
||||||
|
if !strings.Contains(string(out), s) {
|
||||||
|
t.Errorf("应包含 %s: %s", s, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range test.wantAbsent {
|
||||||
|
if strings.Contains(string(out), s) {
|
||||||
|
t.Errorf("不应包含 %s: %s", s, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRespStreamTextAndTool(t *testing.T) {
|
// TestRespCompatHistoryAndToolChoice 断言多轮历史 function_call 与对象 tool_choice
|
||||||
st := NewRespStream("resp_x", "m", 1751966400)
|
// 的 namespace 字段被重限定并删除。
|
||||||
var types []string
|
func TestRespCompatHistoryAndToolChoice(t *testing.T) {
|
||||||
collect := func(evs []RespEvent) {
|
raw := `{"model":"m",
|
||||||
for _, ev := range evs {
|
"tool_choice":{"type":"function","name":"spawn_agent","namespace":"multi_agent_v1"},
|
||||||
types = append(types, ev.Event)
|
"input":[
|
||||||
|
{"type":"function_call","name":"spawn_agent","namespace":"multi_agent_v1","call_id":"c1","arguments":"{}"},
|
||||||
|
{"type":"function_call","name":"exec","call_id":"c2","arguments":"{}"},
|
||||||
|
{"type":"function_call_output","call_id":"c1","output":"ok"}
|
||||||
|
],
|
||||||
|
"tools":[{"type":"namespace","name":"multi_agent_v1","tools":[{"type":"function","name":"spawn_agent","parameters":{}}]}]}`
|
||||||
|
out, _, err := RespPassthroughBody([]byte(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RespPassthroughBody: %v", err)
|
||||||
|
}
|
||||||
|
s := string(out)
|
||||||
|
if !strings.Contains(s, `"tool_choice":{"name":"multi_agent_v1__spawn_agent","type":"function"}`) {
|
||||||
|
t.Errorf("tool_choice 应限定化: %s", s)
|
||||||
|
}
|
||||||
|
if strings.Contains(s, `"namespace":"multi_agent_v1"`) {
|
||||||
|
t.Errorf("namespace 字段应全部删除: %s", s)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, `"name":"exec"`) {
|
||||||
|
t.Errorf("无 namespace 的历史项应原样: %s", s)
|
||||||
|
}
|
||||||
|
if c := strings.Count(s, "multi_agent_v1__spawn_agent"); c != 3 {
|
||||||
|
t.Errorf("限定名应出现 3 次(tools/input/tool_choice), got %d: %s", c, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRespRestoreNamespace 断言非流式响应把限定名还原为短名+namespace 双字段。
|
||||||
|
func TestRespRestoreNamespace(t *testing.T) {
|
||||||
|
compat := RespCompat{NsRefs: map[string]RespNsRef{"multi_agent_v1__spawn_agent": {Namespace: "multi_agent_v1", Name: "spawn_agent"}}}
|
||||||
|
payload := []byte(`{"id":"r1","output":[{"type":"function_call","name":"multi_agent_v1__spawn_agent","call_id":"c1","arguments":"{}"},{"type":"message","content":[]}],"usage":{"total_tokens":7}}`)
|
||||||
|
out := RespRestoreToolCalls(payload, compat)
|
||||||
|
s := string(out)
|
||||||
|
if !strings.Contains(s, `"name":"spawn_agent"`) || !strings.Contains(s, `"namespace":"multi_agent_v1"`) {
|
||||||
|
t.Errorf("应还原短名并补 namespace: %s", s)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, `"total_tokens":7`) {
|
||||||
|
t.Errorf("其余字段应保真: %s", s)
|
||||||
|
}
|
||||||
|
for name, payload := range map[string][]byte{
|
||||||
|
"未命中原样": []byte(`{"output":[{"type":"function_call","name":"other","call_id":"c"}]}`),
|
||||||
|
"非法JSON": []byte(`xx`),
|
||||||
|
} {
|
||||||
|
if got := RespRestoreToolCalls(payload, compat); string(got) != string(payload) {
|
||||||
|
t.Errorf("%s: 应返回原字节, got %s", name, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
collect(st.Feed(chunkText("你")))
|
if got := RespRestoreToolCalls(payload, RespCompat{}); string(got) != string(payload) {
|
||||||
collect(st.Feed(chunkText("好")))
|
t.Error("无需还原时应返回原字节")
|
||||||
collect(st.Feed(aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{
|
|
||||||
ToolCalls: []aiwire.ToolCallDelta{{Index: 0, ID: "call_1", Function: aiwire.FunctionCallDelta{Name: "f", Arguments: "{\"a\""}}}}}}}))
|
|
||||||
collect(st.Feed(aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{
|
|
||||||
ToolCalls: []aiwire.ToolCallDelta{{Index: 0, Function: aiwire.FunctionCallDelta{Arguments: ":1}"}}}}}},
|
|
||||||
Usage: &aiwire.Usage{PromptTokens: 3, CompletionTokens: 7, TotalTokens: 10}}))
|
|
||||||
finish := st.Finish()
|
|
||||||
collect(finish)
|
|
||||||
want := []string{
|
|
||||||
"response.created", "response.in_progress",
|
|
||||||
"response.output_item.added", "response.content_part.added", "response.output_text.delta",
|
|
||||||
"response.output_text.delta",
|
|
||||||
"response.output_text.done", "response.content_part.done", "response.output_item.done",
|
|
||||||
"response.output_item.added", "response.function_call_arguments.delta",
|
|
||||||
"response.function_call_arguments.delta",
|
|
||||||
"response.function_call_arguments.done", "response.output_item.done",
|
|
||||||
"response.completed",
|
|
||||||
}
|
|
||||||
if strings.Join(types, "\n") != strings.Join(want, "\n") {
|
|
||||||
t.Errorf("事件序列 =\n%s\nwant\n%s", strings.Join(types, "\n"), strings.Join(want, "\n"))
|
|
||||||
}
|
|
||||||
last := finish[len(finish)-1].Data
|
|
||||||
resp := last["response"].(aiwire.Response)
|
|
||||||
if len(resp.Output) != 2 || resp.Output[0].Content[0].Text != "你好" || resp.Output[1].Arguments != "{\"a\":1}" {
|
|
||||||
t.Errorf("终态快照 = %+v", resp.Output)
|
|
||||||
}
|
|
||||||
if resp.Usage == nil || resp.Usage.TotalTokens != 10 {
|
|
||||||
t.Errorf("终态 usage = %+v", resp.Usage)
|
|
||||||
}
|
|
||||||
// sequence_number 严格递增
|
|
||||||
if seq, ok := last["sequence_number"].(int); !ok || seq != len(want) {
|
|
||||||
t.Errorf("最终 sequence_number = %v, want %d", last["sequence_number"], len(want))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRespStreamEmpty(t *testing.T) {
|
// TestRespRestoreNamespaceEvent 断言流式事件还原覆盖 item 与 response.output
|
||||||
st := NewRespStream("resp_e", "m", 1)
|
// 两个位置,无关事件保持未改动。
|
||||||
evs := st.Finish()
|
func TestRespRestoreNamespaceEvent(t *testing.T) {
|
||||||
if len(evs) != 2 || evs[0].Event != "response.created" || evs[1].Event != "response.completed" {
|
compat := RespCompat{NsRefs: map[string]RespNsRef{"ns1__run": {Namespace: "ns1", Name: "run"}}}
|
||||||
t.Errorf("空流事件 = %+v", evs)
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
data string
|
||||||
|
wantChanged bool
|
||||||
|
wantSub string
|
||||||
|
}{
|
||||||
|
{"output_item.done", `{"type":"response.output_item.done","item":{"type":"function_call","name":"ns1__run","call_id":"c1"}}`, true, `"namespace":"ns1"`},
|
||||||
|
{"completed快照", `{"type":"response.completed","response":{"output":[{"type":"function_call","name":"ns1__run"}],"usage":{"total_tokens":1}}}`, true, `"name":"run"`},
|
||||||
|
{"文本增量不动", `{"type":"response.output_text.delta","delta":"hi"}`, false, ""},
|
||||||
|
{"未命中不动", `{"type":"response.output_item.done","item":{"type":"function_call","name":"exec"}}`, false, ""},
|
||||||
|
{"非法JSON不动", `not-json`, false, ""},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
out, changed := RespRestoreToolCallsEvent([]byte(test.data), compat)
|
||||||
|
if changed != test.wantChanged {
|
||||||
|
t.Fatalf("changed = %v, want %v", changed, test.wantChanged)
|
||||||
|
}
|
||||||
|
if !changed && string(out) != test.data {
|
||||||
|
t.Fatalf("未改动应返回原字节: %s", out)
|
||||||
|
}
|
||||||
|
if test.wantSub != "" && !strings.Contains(string(out), test.wantSub) {
|
||||||
|
t.Fatalf("应包含 %s: %s", test.wantSub, out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRespPassthroughUsage(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
payload string
|
||||||
|
wantNil bool
|
||||||
|
prompt, cached int
|
||||||
|
}{
|
||||||
|
{"完整", `{"usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12,"input_tokens_details":{"cached_tokens":5}}}`, false, 9, 5},
|
||||||
|
{"无细分", `{"usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12}}`, false, 9, 0},
|
||||||
|
{"无usage", `{"id":"resp_1"}`, true, 0, 0},
|
||||||
|
{"非法JSON", `xx`, true, 0, 0},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
usage := RespPassthroughUsage([]byte(test.payload))
|
||||||
|
if (usage == nil) != test.wantNil {
|
||||||
|
t.Fatalf("usage = %v, wantNil %v", usage, test.wantNil)
|
||||||
|
}
|
||||||
|
if usage == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if usage.PromptTokens != test.prompt || usage.CachedTokens() != test.cached {
|
||||||
|
t.Fatalf("prompt=%d cached=%d, want %d/%d", usage.PromptTokens, usage.CachedTokens(), test.prompt, test.cached)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRespStreamCompletedUsage 断言流式 usage 只从 completed 事件提取。
|
||||||
|
func TestRespStreamCompletedUsage(t *testing.T) {
|
||||||
|
completed := []byte(`{"type":"response.completed","response":{"usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15,"input_tokens_details":{"cached_tokens":4}}}}`)
|
||||||
|
if u := RespStreamCompletedUsage(completed); u == nil || u.PromptTokens != 10 || u.CompletionTokens != 5 ||
|
||||||
|
u.PromptTokensDetails == nil || u.PromptTokensDetails.CachedTokens != 4 {
|
||||||
|
t.Fatalf("completed 事件 usage 解析失败: %+v", u)
|
||||||
|
}
|
||||||
|
for _, data := range []string{
|
||||||
|
`{"type":"response.output_text.delta","delta":"hi"}`,
|
||||||
|
`{"type":"response.completed"}`,
|
||||||
|
`not-json`,
|
||||||
|
} {
|
||||||
|
if u := RespStreamCompletedUsage([]byte(data)); u != nil {
|
||||||
|
t.Fatalf("非 completed 或缺 response 应返回 nil: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 且其余字段保留。
|
||||||
|
func TestRespDisableStream(t *testing.T) {
|
||||||
|
out, err := RespDisableStream([]byte(`{"model":"m","stream":true,"max_output_tokens":64}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RespDisableStream: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(out), `"stream":false`) || !strings.Contains(string(out), `"max_output_tokens":64`) {
|
||||||
|
t.Fatalf("字段不符: %s", out)
|
||||||
|
}
|
||||||
|
if _, err := RespDisableStream([]byte("x")); err == nil {
|
||||||
|
t.Error("非法 JSON 应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRespSynthSSEEvents 断言合成事件序列:created(in_progress,空 output)→
|
||||||
|
// 逐项 output_item.done → completed(完整响应)。
|
||||||
|
func TestRespSynthSSEEvents(t *testing.T) {
|
||||||
|
payload := []byte(`{"id":"r1","status":"completed","output":[{"type":"reasoning","summary":[]},{"type":"function_call","name":"run","call_id":"c1"}],"usage":{"total_tokens":9}}`)
|
||||||
|
events, err := RespSynthSSEEvents(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RespSynthSSEEvents: %v", err)
|
||||||
|
}
|
||||||
|
if len(events) != 4 {
|
||||||
|
t.Fatalf("事件数 = %d, want 4", len(events))
|
||||||
|
}
|
||||||
|
first := string(events[0])
|
||||||
|
if !strings.Contains(first, `"type":"response.created"`) || !strings.Contains(first, `"status":"in_progress"`) || !strings.Contains(first, `"output":[]`) {
|
||||||
|
t.Errorf("created 事件不符: %s", first)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(events[1]), `"type":"response.output_item.done"`) || !strings.Contains(string(events[1]), `"output_index":0`) {
|
||||||
|
t.Errorf("item.done 事件不符: %s", events[1])
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(events[2]), `"name":"run"`) {
|
||||||
|
t.Errorf("第二项应为 function_call: %s", events[2])
|
||||||
|
}
|
||||||
|
last := string(events[3])
|
||||||
|
if !strings.Contains(last, `"type":"response.completed"`) || !strings.Contains(last, `"status":"completed"`) ||
|
||||||
|
!strings.Contains(last, `"total_tokens":9`) || !strings.Contains(last, `"name":"run"`) {
|
||||||
|
t.Errorf("completed 事件不符: %s", last)
|
||||||
|
}
|
||||||
|
if _, err := RespSynthSSEEvents([]byte("x")); err == nil {
|
||||||
|
t.Error("非法 JSON 应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRespCompatCustomTools 断言 custom 工具转换:apply_patch 丢弃,其余转
|
||||||
|
// function 并补 input 包装 schema、记入 CustomNames;format 字段移除。
|
||||||
|
func TestRespCompatCustomTools(t *testing.T) {
|
||||||
|
raw := `{"model":"m","tools":[
|
||||||
|
{"type":"custom","name":"apply_patch","description":"edit files"},
|
||||||
|
{"type":"custom","name":"run_script","description":"run it","format":{"type":"grammar"}},
|
||||||
|
{"type":"function","name":"exec","parameters":{}}]}`
|
||||||
|
out, compat, err := RespPassthroughBody([]byte(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RespPassthroughBody: %v", err)
|
||||||
|
}
|
||||||
|
if !slices.Equal(compat.Dropped, []string{"custom:apply_patch"}) {
|
||||||
|
t.Errorf("Dropped = %v", compat.Dropped)
|
||||||
|
}
|
||||||
|
if !slices.Equal(compat.Converted, []string{"custom:run_script"}) {
|
||||||
|
t.Errorf("Converted = %v", compat.Converted)
|
||||||
|
}
|
||||||
|
if _, ok := compat.CustomNames["run_script"]; !ok {
|
||||||
|
t.Errorf("CustomNames 缺 run_script: %v", compat.CustomNames)
|
||||||
|
}
|
||||||
|
s := string(out)
|
||||||
|
if strings.Contains(s, "apply_patch") || strings.Contains(s, `"type":"custom"`) || strings.Contains(s, "grammar") {
|
||||||
|
t.Errorf("apply_patch/custom/format 应消失: %s", s)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, `"required":["input"]`) || !strings.Contains(s, `"input":{"type":"string"}`) {
|
||||||
|
t.Errorf("run_script 应补 input 包装 schema: %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRespCompatCustomHistory 断言 input 历史的 custom_tool_call(_output) 转换
|
||||||
|
// 与 arguments 包装三分支。
|
||||||
|
func TestRespCompatCustomHistory(t *testing.T) {
|
||||||
|
raw := `{"model":"m","input":[
|
||||||
|
{"type":"custom_tool_call","call_id":"c1","name":"run_script","input":"plain text"},
|
||||||
|
{"type":"custom_tool_call","call_id":"c2","name":"run_script","input":"{\"a\":1}"},
|
||||||
|
{"type":"custom_tool_call","call_id":"c3","name":"run_script"},
|
||||||
|
{"type":"custom_tool_call_output","call_id":"c1","output":"done"},
|
||||||
|
{"type":"custom_tool_call_output","call_id":"c2","output":{"ok":true}}]}`
|
||||||
|
out, _, err := RespPassthroughBody([]byte(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RespPassthroughBody: %v", err)
|
||||||
|
}
|
||||||
|
s := string(out)
|
||||||
|
if strings.Contains(s, "custom_tool_call") {
|
||||||
|
t.Fatalf("custom_tool_call 系应全部转换: %s", s)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
`"arguments":"{\"input\":\"plain text\"}"`,
|
||||||
|
`"arguments":"{\"a\":1}"`,
|
||||||
|
`"arguments":"{}"`,
|
||||||
|
`"output":"done"`,
|
||||||
|
`"output":"{\"ok\":true}"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(s, want) {
|
||||||
|
t.Errorf("应包含 %s: %s", want, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRespRestoreCustomToolCalls 断言响应侧回转:命中 CustomNames 的
|
||||||
|
// function_call 变 custom_tool_call 且 input 解包;流式事件同样生效。
|
||||||
|
func TestRespRestoreCustomToolCalls(t *testing.T) {
|
||||||
|
compat := RespCompat{CustomNames: map[string]struct{}{"run_script": {}}}
|
||||||
|
payload := []byte(`{"output":[{"type":"function_call","name":"run_script","call_id":"c1","arguments":"{\"input\":\"echo hi\"}"},{"type":"function_call","name":"exec","call_id":"c2","arguments":"{}"}]}`)
|
||||||
|
s := string(RespRestoreToolCalls(payload, compat))
|
||||||
|
if !strings.Contains(s, `"type":"custom_tool_call"`) || !strings.Contains(s, `"input":"echo hi"`) {
|
||||||
|
t.Errorf("应回转 custom_tool_call 并解包 input: %s", s)
|
||||||
|
}
|
||||||
|
if strings.Contains(s, `"arguments":"{\"input\":\"echo hi\"}"`) {
|
||||||
|
t.Errorf("arguments 应删除: %s", s)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, `"name":"exec"`) || strings.Count(s, "custom_tool_call") != 1 {
|
||||||
|
t.Errorf("非转换名不应动: %s", s)
|
||||||
|
}
|
||||||
|
ev := []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"run_script","arguments":"not-json"}}`)
|
||||||
|
got, changed := RespRestoreToolCallsEvent(ev, compat)
|
||||||
|
if !changed || !strings.Contains(string(got), `"input":"not-json"`) {
|
||||||
|
t.Errorf("非 JSON arguments 应整串作 input: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRespUnwrapCustomInput 断言解包分支:input 键取值、非串取原文、其余整串。
|
||||||
|
func TestRespUnwrapCustomInput(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args any
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"包装取input", `{"input":"hello"}`, "hello"},
|
||||||
|
{"input非串取原文", `{"input":{"x":1}}`, `{"x":1}`},
|
||||||
|
{"无input键整串", `{"cmd":"ls"}`, `{"cmd":"ls"}`},
|
||||||
|
{"非JSON整串", "raw text", "raw text"},
|
||||||
|
{"非串类型空串", 42, ""},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if got := respUnwrapCustomInput(test.args); got != test.want {
|
||||||
|
t.Fatalf("respUnwrapCustomInput(%v) = %q, want %q", test.args, got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRespCompatToolSearchDrop 断言 tool_search 剥离(拍平后语义冗余,上游不识别)。
|
||||||
|
func TestRespCompatToolSearchDrop(t *testing.T) {
|
||||||
|
raw := `{"model":"m","tools":[{"type":"tool_search"},{"type":"function","name":"exec"}]}`
|
||||||
|
out, compat, err := RespPassthroughBody([]byte(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RespPassthroughBody: %v", err)
|
||||||
|
}
|
||||||
|
if !slices.Equal(compat.Dropped, []string{"tool_search"}) {
|
||||||
|
t.Errorf("Dropped = %v", compat.Dropped)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(out), "tool_search") || !strings.Contains(string(out), `"name":"exec"`) {
|
||||||
|
t.Errorf("tool_search 应剥离且 function 保留: %s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,305 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/netip"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
|
|
||||||
"oci-portal/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 告警规则约束与命中记录保留期(窗口计数之外多留几天便于排查)。
|
|
||||||
const (
|
|
||||||
alertMaxThreshold = 100
|
|
||||||
alertMaxWindowMin = 1440
|
|
||||||
alertHitRetention = 7 * 24 * time.Hour
|
|
||||||
alertSourceIPIn = "in"
|
|
||||||
alertSourceIPNotIn = "notin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrInvalidAlertRule 标记规则字段非法,api 层映射 400。
|
|
||||||
var ErrInvalidAlertRule = fmt.Errorf("告警规则字段非法")
|
|
||||||
|
|
||||||
// ListAlertRules 返回全部告警规则(创建顺序)。
|
|
||||||
func (s *LogEventService) ListAlertRules(ctx context.Context) ([]model.AlertRule, error) {
|
|
||||||
var rules []model.AlertRule
|
|
||||||
if err := s.db.WithContext(ctx).Order("id").Find(&rules).Error; err != nil {
|
|
||||||
return nil, fmt.Errorf("list alert rules: %w", err)
|
|
||||||
}
|
|
||||||
return rules, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateAlertRule 校验并创建规则。
|
|
||||||
func (s *LogEventService) CreateAlertRule(ctx context.Context, rule model.AlertRule) (model.AlertRule, error) {
|
|
||||||
if err := validateAlertRule(&rule); err != nil {
|
|
||||||
return model.AlertRule{}, err
|
|
||||||
}
|
|
||||||
rule.ID = 0
|
|
||||||
if err := s.db.WithContext(ctx).Create(&rule).Error; err != nil {
|
|
||||||
return model.AlertRule{}, fmt.Errorf("create alert rule: %w", err)
|
|
||||||
}
|
|
||||||
return rule, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateAlertRule 校验并整体覆盖规则(含启停)。
|
|
||||||
func (s *LogEventService) UpdateAlertRule(ctx context.Context, id uint, rule model.AlertRule) (model.AlertRule, error) {
|
|
||||||
if err := validateAlertRule(&rule); err != nil {
|
|
||||||
return model.AlertRule{}, err
|
|
||||||
}
|
|
||||||
var cur model.AlertRule
|
|
||||||
if err := s.db.WithContext(ctx).First(&cur, id).Error; err != nil {
|
|
||||||
return model.AlertRule{}, fmt.Errorf("find alert rule %d: %w", id, err)
|
|
||||||
}
|
|
||||||
rule.ID, rule.CreatedAt = cur.ID, cur.CreatedAt
|
|
||||||
if err := s.db.WithContext(ctx).Save(&rule).Error; err != nil {
|
|
||||||
return model.AlertRule{}, fmt.Errorf("update alert rule: %w", err)
|
|
||||||
}
|
|
||||||
return rule, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteAlertRule 删除规则及其命中记录。
|
|
||||||
func (s *LogEventService) DeleteAlertRule(ctx context.Context, id uint) error {
|
|
||||||
if err := s.db.WithContext(ctx).Delete(&model.AlertRule{}, id).Error; err != nil {
|
|
||||||
return fmt.Errorf("delete alert rule: %w", err)
|
|
||||||
}
|
|
||||||
if err := s.db.WithContext(ctx).Where("rule_id = ?", id).Delete(&model.AlertRuleHit{}).Error; err != nil {
|
|
||||||
return fmt.Errorf("delete alert rule hits: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateAlertRule 校验字段并归一化;非法时返回含具体原因的 ErrInvalidAlertRule 包装。
|
|
||||||
func validateAlertRule(rule *model.AlertRule) error {
|
|
||||||
rule.Name = strings.TrimSpace(rule.Name)
|
|
||||||
if rule.Name == "" {
|
|
||||||
return fmt.Errorf("%w: 名称必填", ErrInvalidAlertRule)
|
|
||||||
}
|
|
||||||
if rule.SourceIPMode == "" {
|
|
||||||
rule.SourceIPMode = alertSourceIPIn
|
|
||||||
}
|
|
||||||
if rule.SourceIPMode != alertSourceIPIn && rule.SourceIPMode != alertSourceIPNotIn {
|
|
||||||
return fmt.Errorf("%w: 来源 IP 模式须为 in/notin", ErrInvalidAlertRule)
|
|
||||||
}
|
|
||||||
if rule.Threshold < 1 || rule.Threshold > alertMaxThreshold {
|
|
||||||
return fmt.Errorf("%w: 阈值须在 1-%d 之间", ErrInvalidAlertRule, alertMaxThreshold)
|
|
||||||
}
|
|
||||||
if rule.Threshold > 1 && (rule.WindowMinutes < 1 || rule.WindowMinutes > alertMaxWindowMin) {
|
|
||||||
return fmt.Errorf("%w: 阈值>1 时窗口须在 1-%d 分钟之间", ErrInvalidAlertRule, alertMaxWindowMin)
|
|
||||||
}
|
|
||||||
if rule.EventTypes != "" {
|
|
||||||
rule.EventTypes = normalizeCSV(rule.EventTypes)
|
|
||||||
}
|
|
||||||
return validateAlertRuleIPs(rule)
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateAlertRuleIPs 归一化并校验来源 IP 列表(裸 IP 或 CIDR)。
|
|
||||||
func validateAlertRuleIPs(rule *model.AlertRule) error {
|
|
||||||
if rule.SourceIPs == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
rule.SourceIPs = normalizeCSV(rule.SourceIPs)
|
|
||||||
for _, item := range strings.Split(rule.SourceIPs, ",") {
|
|
||||||
if _, err := parseIPMatcher(item); err != nil {
|
|
||||||
return fmt.Errorf("%w: 来源 IP %q 不是合法的 IP 或 CIDR", ErrInvalidAlertRule, item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizeCSV 去除各项空白与空项后重组逗号分隔串。
|
|
||||||
func normalizeCSV(s string) string {
|
|
||||||
parts := strings.Split(s, ",")
|
|
||||||
out := parts[:0]
|
|
||||||
for _, p := range parts {
|
|
||||||
if p = strings.TrimSpace(p); p != "" {
|
|
||||||
out = append(out, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Join(out, ",")
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseIPMatcher 把裸 IP 或 CIDR 解析为前缀(裸 IP 视为单地址前缀)。
|
|
||||||
func parseIPMatcher(item string) (netip.Prefix, error) {
|
|
||||||
if strings.Contains(item, "/") {
|
|
||||||
return netip.ParsePrefix(item)
|
|
||||||
}
|
|
||||||
addr, err := netip.ParseAddr(item)
|
|
||||||
if err != nil {
|
|
||||||
return netip.Prefix{}, err
|
|
||||||
}
|
|
||||||
return netip.PrefixFrom(addr, addr.BitLen()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ipListMatch 报告 ip 是否命中列表中的任一前缀;ip 解析失败视为未命中。
|
|
||||||
func ipListMatch(list, ip string) bool {
|
|
||||||
addr, err := netip.ParseAddr(ip)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, item := range strings.Split(list, ",") {
|
|
||||||
if p, err := parseIPMatcher(item); err == nil && p.Contains(addr) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ruleHits 报告事件是否命中规则的全部条件(AND 语义,空条件视为任意)。
|
|
||||||
func ruleHits(rule model.AlertRule, e *model.LogEvent, p parsedEvent) bool {
|
|
||||||
if rule.OciConfigID != 0 && rule.OciConfigID != e.OciConfigID {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
name := relayEventShortName(p.EventType)
|
|
||||||
if rule.EventTypes != "" && !slices.Contains(strings.Split(rule.EventTypes, ","), name) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if rule.ResourceMatch != "" && !strings.Contains(p.ResourceName, rule.ResourceMatch) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return ruleIPHits(rule, p.SourceIP)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ruleIPHits 按模式判定来源 IP 条件:in 命中列表告警;notin 不在列表才告警,
|
|
||||||
// 事件缺 IP 字段时 notin 不告警(避免解析缺字段导致白名单误报)。
|
|
||||||
func ruleIPHits(rule model.AlertRule, ip string) bool {
|
|
||||||
if rule.SourceIPs == "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if rule.SourceIPMode == alertSourceIPNotIn {
|
|
||||||
return ip != "" && !ipListMatch(rule.SourceIPs, ip)
|
|
||||||
}
|
|
||||||
return ipListMatch(rule.SourceIPs, ip)
|
|
||||||
}
|
|
||||||
|
|
||||||
// matchAlertRules 对一条已解析事件执行全部启用规则;任何内部错误只记日志,不影响解析主流程。
|
|
||||||
func (s *LogEventService) matchAlertRules(ctx context.Context, rules []model.AlertRule, e *model.LogEvent, p parsedEvent) {
|
|
||||||
if s.notifier == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, rule := range rules {
|
|
||||||
if !rule.Enabled || !ruleHits(rule, e, p) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
count, ok := s.recordAlertHit(ctx, rule, e)
|
|
||||||
if !ok || count < rule.Threshold || !s.alertCooldownPass(rule) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
s.notifier.SendTemplateAsync("audit_alert", map[string]string{
|
|
||||||
"rule": rule.Name, "tenant": s.configAlias(ctx, e.OciConfigID),
|
|
||||||
"event": relayEventShortName(p.EventType), "resource": p.ResourceName,
|
|
||||||
"ip": p.SourceIP, "count": fmt.Sprint(count),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// recordAlertHit 落一条命中并返回窗口内累计次数;阈值 1 的规则免计数直接触发。
|
|
||||||
func (s *LogEventService) recordAlertHit(ctx context.Context, rule model.AlertRule, e *model.LogEvent) (int, bool) {
|
|
||||||
count, err := s.recordAlertHitTx(ctx, rule, e)
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
log.Printf("alert rule hit record: %v", err)
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return count, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *LogEventService) recordAlertHitTx(ctx context.Context, rule model.AlertRule, event *model.LogEvent) (int, error) {
|
|
||||||
count := 0
|
|
||||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
var err error
|
|
||||||
count, err = insertAlertHit(tx, rule, event)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// insertAlertHit 按 rule→event 锁顺序确认引用存在后插入并统计窗口命中。
|
|
||||||
func insertAlertHit(tx *gorm.DB, rule model.AlertRule, event *model.LogEvent) (int, error) {
|
|
||||||
if err := lockAlertRefs(tx, rule.ID, event.ID); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
if rule.Threshold <= 1 {
|
|
||||||
return 1, nil
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
hit := model.AlertRuleHit{RuleID: rule.ID, LogEventID: event.ID, HitAt: now}
|
|
||||||
if err := tx.Create(&hit).Error; err != nil {
|
|
||||||
return 0, fmt.Errorf("create alert rule hit: %w", err)
|
|
||||||
}
|
|
||||||
var count int64
|
|
||||||
cutoff := now.Add(-time.Duration(rule.WindowMinutes) * time.Minute)
|
|
||||||
err := tx.Model(&model.AlertRuleHit{}).
|
|
||||||
Where("rule_id = ? AND hit_at >= ?", rule.ID, cutoff).Count(&count).Error
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("count alert rule hits: %w", err)
|
|
||||||
}
|
|
||||||
return int(count), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func lockAlertRefs(tx *gorm.DB, ruleID, eventID uint) error {
|
|
||||||
var rule model.AlertRule
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&rule, ruleID).Error; err != nil {
|
|
||||||
return fmt.Errorf("lock alert rule %d: %w", ruleID, err)
|
|
||||||
}
|
|
||||||
var event model.LogEvent
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&event, eventID).Error; err != nil {
|
|
||||||
return fmt.Errorf("lock log event %d: %w", eventID, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// alertCooldownPass 报告规则是否已过冷却窗口;通过即记录本次发送时刻。
|
|
||||||
// 阈值 1 的规则无冷却(每次命中即时告警,与既有云端事件通知一致)。
|
|
||||||
func (s *LogEventService) alertCooldownPass(rule model.AlertRule) bool {
|
|
||||||
if rule.Threshold <= 1 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
s.alertMu.Lock()
|
|
||||||
defer s.alertMu.Unlock()
|
|
||||||
window := time.Duration(rule.WindowMinutes) * time.Minute
|
|
||||||
if last, ok := s.alertSentAt[rule.ID]; ok && time.Since(last) < window {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if s.alertSentAt == nil {
|
|
||||||
s.alertSentAt = map[uint]time.Time{}
|
|
||||||
}
|
|
||||||
s.alertSentAt[rule.ID] = time.Now()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClearAlertCooldown 清除已删除租户规则的进程内冷却状态。
|
|
||||||
func (s *LogEventService) ClearAlertCooldown(ruleIDs []uint) {
|
|
||||||
s.alertMu.Lock()
|
|
||||||
defer s.alertMu.Unlock()
|
|
||||||
for _, id := range ruleIDs {
|
|
||||||
delete(s.alertSentAt, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadEnabledAlertRules 载入启用中的规则;失败时返回空集并记日志(解析主流程照常)。
|
|
||||||
func (s *LogEventService) loadEnabledAlertRules(ctx context.Context) []model.AlertRule {
|
|
||||||
var rules []model.AlertRule
|
|
||||||
err := s.db.WithContext(ctx).Where("enabled = ?", true).Order("id").Find(&rules).Error
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("load alert rules: %v", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return rules
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanupAlertHits 删除保留期外的命中记录(随 cleanupOnce 周期执行)。
|
|
||||||
func (s *LogEventService) cleanupAlertHits(ctx context.Context) {
|
|
||||||
cutoff := time.Now().Add(-alertHitRetention)
|
|
||||||
if err := s.db.WithContext(ctx).Where("hit_at < ?", cutoff).Delete(&model.AlertRuleHit{}).Error; err != nil {
|
|
||||||
log.Printf("cleanup alert hits: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"oci-portal/internal/crypto"
|
|
||||||
"oci-portal/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestValidateAlertRule(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
rule model.AlertRule
|
|
||||||
wantErr string
|
|
||||||
check func(t *testing.T, r model.AlertRule)
|
|
||||||
}{
|
|
||||||
{name: "名称必填", rule: model.AlertRule{Threshold: 1}, wantErr: "名称"},
|
|
||||||
{name: "模式非法", rule: model.AlertRule{Name: "r", Threshold: 1, SourceIPMode: "any"}, wantErr: "in/notin"},
|
|
||||||
{name: "阈值越界", rule: model.AlertRule{Name: "r", Threshold: 101}, wantErr: "阈值"},
|
|
||||||
{name: "阈值>1须带窗口", rule: model.AlertRule{Name: "r", Threshold: 3}, wantErr: "窗口"},
|
|
||||||
{name: "IP 非法", rule: model.AlertRule{Name: "r", Threshold: 1, SourceIPs: "300.1.1.1"}, wantErr: "IP"},
|
|
||||||
{name: "CIDR 合法", rule: model.AlertRule{Name: "r", Threshold: 1, SourceIPs: "10.0.0.0/8, 1.2.3.4"},
|
|
||||||
check: func(t *testing.T, r model.AlertRule) {
|
|
||||||
if r.SourceIPs != "10.0.0.0/8,1.2.3.4" {
|
|
||||||
t.Errorf("SourceIPs = %q, 应去空白归一化", r.SourceIPs)
|
|
||||||
}
|
|
||||||
if r.SourceIPMode != alertSourceIPIn {
|
|
||||||
t.Errorf("SourceIPMode = %q, 应默认 in", r.SourceIPMode)
|
|
||||||
}
|
|
||||||
}},
|
|
||||||
{name: "事件清单归一化", rule: model.AlertRule{Name: "r", Threshold: 1, EventTypes: " TerminateInstance , CreateApiKey ,"},
|
|
||||||
check: func(t *testing.T, r model.AlertRule) {
|
|
||||||
if r.EventTypes != "TerminateInstance,CreateApiKey" {
|
|
||||||
t.Errorf("EventTypes = %q", r.EventTypes)
|
|
||||||
}
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
rule := tt.rule
|
|
||||||
err := validateAlertRule(&rule)
|
|
||||||
if tt.wantErr == "" {
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("validateAlertRule: %v", err)
|
|
||||||
}
|
|
||||||
if tt.check != nil {
|
|
||||||
tt.check(t, rule)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
|
||||||
t.Fatalf("err = %v, want contains %q", err, tt.wantErr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRuleHits(t *testing.T) {
|
|
||||||
base := model.AlertRule{Name: "r", Threshold: 1, SourceIPMode: alertSourceIPIn}
|
|
||||||
ev := &model.LogEvent{OciConfigID: 7}
|
|
||||||
parsed := parsedEvent{
|
|
||||||
EventType: "com.oraclecloud.ComputeApi.TerminateInstance",
|
|
||||||
SourceIP: "203.0.113.8",
|
|
||||||
ResourceName: "web-server-1",
|
|
||||||
}
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
mod func(r *model.AlertRule)
|
|
||||||
p *parsedEvent
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{name: "空条件全命中", mod: func(r *model.AlertRule) {}, want: true},
|
|
||||||
{name: "租户匹配", mod: func(r *model.AlertRule) { r.OciConfigID = 7 }, want: true},
|
|
||||||
{name: "租户不匹配", mod: func(r *model.AlertRule) { r.OciConfigID = 8 }, want: false},
|
|
||||||
{name: "事件短名命中", mod: func(r *model.AlertRule) { r.EventTypes = "LaunchInstance,TerminateInstance" }, want: true},
|
|
||||||
{name: "事件不在清单", mod: func(r *model.AlertRule) { r.EventTypes = "CreateUser" }, want: false},
|
|
||||||
{name: "资源子串命中", mod: func(r *model.AlertRule) { r.ResourceMatch = "web-" }, want: true},
|
|
||||||
{name: "资源不含", mod: func(r *model.AlertRule) { r.ResourceMatch = "db-" }, want: false},
|
|
||||||
{name: "IP in 命中 CIDR", mod: func(r *model.AlertRule) { r.SourceIPs = "203.0.113.0/24" }, want: true},
|
|
||||||
{name: "IP in 未命中", mod: func(r *model.AlertRule) { r.SourceIPs = "10.0.0.0/8" }, want: false},
|
|
||||||
{name: "IP notin 白名单外告警", mod: func(r *model.AlertRule) {
|
|
||||||
r.SourceIPs, r.SourceIPMode = "10.0.0.0/8", alertSourceIPNotIn
|
|
||||||
}, want: true},
|
|
||||||
{name: "IP notin 白名单内不告警", mod: func(r *model.AlertRule) {
|
|
||||||
r.SourceIPs, r.SourceIPMode = "203.0.113.8", alertSourceIPNotIn
|
|
||||||
}, want: false},
|
|
||||||
{name: "notin 事件缺 IP 不告警", mod: func(r *model.AlertRule) {
|
|
||||||
r.SourceIPs, r.SourceIPMode = "10.0.0.0/8", alertSourceIPNotIn
|
|
||||||
}, p: &parsedEvent{EventType: parsed.EventType}, want: false},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
rule := base
|
|
||||||
tt.mod(&rule)
|
|
||||||
p := parsed
|
|
||||||
if tt.p != nil {
|
|
||||||
p = *tt.p
|
|
||||||
}
|
|
||||||
if got := ruleHits(rule, ev, p); got != tt.want {
|
|
||||||
t.Errorf("ruleHits = %v, want %v", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertRuleCRUD(t *testing.T) {
|
|
||||||
svc, _, _ := newLogEventEnv(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
created, err := svc.CreateAlertRule(ctx, model.AlertRule{Name: "非白名单终止", Enabled: true, Threshold: 1})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create: %v", err)
|
|
||||||
}
|
|
||||||
if created.ID == 0 {
|
|
||||||
t.Fatal("create 未回填 ID")
|
|
||||||
}
|
|
||||||
if _, err := svc.CreateAlertRule(ctx, model.AlertRule{Threshold: 1}); err == nil {
|
|
||||||
t.Fatal("空名称应校验失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
created.Enabled = false
|
|
||||||
created.EventTypes = "TerminateInstance"
|
|
||||||
updated, err := svc.UpdateAlertRule(ctx, created.ID, created)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("update: %v", err)
|
|
||||||
}
|
|
||||||
if updated.Enabled || updated.EventTypes != "TerminateInstance" {
|
|
||||||
t.Fatalf("update 未生效: %+v", updated)
|
|
||||||
}
|
|
||||||
|
|
||||||
rules, err := svc.ListAlertRules(ctx)
|
|
||||||
if err != nil || len(rules) != 1 {
|
|
||||||
t.Fatalf("list = %v, %v", rules, err)
|
|
||||||
}
|
|
||||||
if err := svc.DeleteAlertRule(ctx, created.ID); err != nil {
|
|
||||||
t.Fatalf("delete: %v", err)
|
|
||||||
}
|
|
||||||
if rules, _ := svc.ListAlertRules(ctx); len(rules) != 0 {
|
|
||||||
t.Fatalf("delete 后仍有 %d 条", len(rules))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// auditEventPayload 构造一条含资源与来源 IP 的 CloudEvents 审计消息。
|
|
||||||
func auditEventPayload(event, resource, ip string) string {
|
|
||||||
return fmt.Sprintf(`{"eventType":"com.oraclecloud.ComputeApi.%s","source":"ComputeApi",`+
|
|
||||||
`"eventTime":"2026-07-10T08:00:00Z","data":{"resourceName":%q,"identity":{"ipAddress":%q}}}`,
|
|
||||||
event, resource, ip)
|
|
||||||
}
|
|
||||||
|
|
||||||
// newAlertNotifyEnv 组装带假 Telegram 通道的告警测试环境。
|
|
||||||
func newAlertNotifyEnv(t *testing.T) (*LogEventService, *telegramCapture, func()) {
|
|
||||||
t.Helper()
|
|
||||||
svc, db, _ := newLogEventEnv(t)
|
|
||||||
srv, rec := newFakeTelegram(t, `{"ok":true}`)
|
|
||||||
cipher, err := crypto.NewCipher("test-data-key")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("new cipher: %v", err)
|
|
||||||
}
|
|
||||||
settings := NewSettingService(db, cipher)
|
|
||||||
token := "123456:AAfake"
|
|
||||||
if err := settings.UpdateTelegram(context.Background(),
|
|
||||||
UpdateTelegramInput{Enabled: true, BotToken: &token, ChatID: "42"}); err != nil {
|
|
||||||
t.Fatalf("update telegram: %v", err)
|
|
||||||
}
|
|
||||||
n := NewNotifier(settings)
|
|
||||||
n.base = srv.URL
|
|
||||||
svc.SetNotifier(n, settings)
|
|
||||||
return svc, rec, n.Wait
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMatchAlertRulesNotify(t *testing.T) {
|
|
||||||
svc, rec, wait := newAlertNotifyEnv(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
_, err := svc.CreateAlertRule(ctx, model.AlertRule{
|
|
||||||
Name: "白名单外终止", Enabled: true, Threshold: 1,
|
|
||||||
EventTypes: "TerminateInstance", SourceIPs: "10.0.0.0/8", SourceIPMode: alertSourceIPNotIn,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create rule: %v", err)
|
|
||||||
}
|
|
||||||
// 命中:白名单外 IP;不命中:白名单内 IP
|
|
||||||
mustIngest(t, svc, "m1", auditEventPayload("TerminateInstance", "web-1", "203.0.113.8"))
|
|
||||||
mustIngest(t, svc, "m2", auditEventPayload("TerminateInstance", "web-2", "10.1.2.3"))
|
|
||||||
svc.parseOnce(ctx)
|
|
||||||
wait()
|
|
||||||
|
|
||||||
alerts := auditAlerts(rec.snapshot())
|
|
||||||
joined := strings.Join(alerts, "\n---\n")
|
|
||||||
if !strings.Contains(joined, "白名单外终止") || !strings.Contains(joined, "web-1") {
|
|
||||||
t.Fatalf("应收到含规则名与资源的告警,got %q", joined)
|
|
||||||
}
|
|
||||||
if strings.Contains(joined, "web-2") {
|
|
||||||
t.Fatalf("白名单内事件不应告警,got %q", joined)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// auditAlerts 过滤出审计告警推送(排除既有 notifyCritical 的云端事件通知)。
|
|
||||||
func auditAlerts(texts []string) []string {
|
|
||||||
var out []string
|
|
||||||
for _, s := range texts {
|
|
||||||
if strings.Contains(s, "审计告警") {
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertThresholdWindow(t *testing.T) {
|
|
||||||
svc, rec, wait := newAlertNotifyEnv(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
_, err := svc.CreateAlertRule(ctx, model.AlertRule{
|
|
||||||
Name: "登录风暴", Enabled: true, Threshold: 3, WindowMinutes: 5, EventTypes: "InteractiveLogin",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create rule: %v", err)
|
|
||||||
}
|
|
||||||
for i := 1; i <= 4; i++ {
|
|
||||||
mustIngest(t, svc, fmt.Sprint("login-", i),
|
|
||||||
auditEventPayload("InteractiveLogin", "user@x.com", "203.0.113.8"))
|
|
||||||
}
|
|
||||||
svc.parseOnce(ctx)
|
|
||||||
wait()
|
|
||||||
|
|
||||||
alerts := auditAlerts(rec.snapshot())
|
|
||||||
if len(alerts) != 1 {
|
|
||||||
t.Fatalf("窗口内 4 次命中应只告警 1 次(第 3 次触发后冷却),got %d 条: %v", len(alerts), alerts)
|
|
||||||
}
|
|
||||||
if !strings.Contains(alerts[0], "3 次") {
|
|
||||||
t.Errorf("告警文案应含累计次数,got %q", alerts[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAlertRuleBadDataDoesNotBlockParse 验证规则表异常不影响解析主流程。
|
|
||||||
func TestAlertRuleBadDataDoesNotBlockParse(t *testing.T) {
|
|
||||||
svc, db, _ := newLogEventEnv(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
// 直插一条绕过校验的坏规则(IP 列表非法)
|
|
||||||
bad := model.AlertRule{Name: "bad", Enabled: true, Threshold: 1, SourceIPs: "not-an-ip"}
|
|
||||||
if err := db.Create(&bad).Error; err != nil {
|
|
||||||
t.Fatalf("insert bad rule: %v", err)
|
|
||||||
}
|
|
||||||
mustIngest(t, svc, "m1", auditEventPayload("TerminateInstance", "web-1", "1.2.3.4"))
|
|
||||||
svc.parseOnce(ctx)
|
|
||||||
|
|
||||||
var e model.LogEvent
|
|
||||||
if err := db.First(&e, "message_id = ?", "m1").Error; err != nil {
|
|
||||||
t.Fatalf("find event: %v", err)
|
|
||||||
}
|
|
||||||
if !e.Processed {
|
|
||||||
t.Fatal("坏规则不应阻塞事件解析")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// mustIngest 落一条回传事件,失败即终止测试。
|
|
||||||
func mustIngest(t *testing.T, svc *LogEventService, msgID, payload string) {
|
|
||||||
t.Helper()
|
|
||||||
if err := svc.Ingest(context.Background(), 1, msgID, []byte(payload), false); err != nil {
|
|
||||||
t.Fatalf("ingest %s: %v", msgID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCleanupAlertHits 验证过期命中记录随清理删除。
|
|
||||||
func TestCleanupAlertHits(t *testing.T) {
|
|
||||||
svc, db, _ := newLogEventEnv(t)
|
|
||||||
old := model.AlertRuleHit{RuleID: 1, HitAt: time.Now().Add(-8 * 24 * time.Hour)}
|
|
||||||
fresh := model.AlertRuleHit{RuleID: 1, HitAt: time.Now()}
|
|
||||||
if err := db.Create(&old).Error; err != nil {
|
|
||||||
t.Fatalf("insert: %v", err)
|
|
||||||
}
|
|
||||||
if err := db.Create(&fresh).Error; err != nil {
|
|
||||||
t.Fatalf("insert: %v", err)
|
|
||||||
}
|
|
||||||
svc.cleanupAlertHits(context.Background())
|
|
||||||
var count int64
|
|
||||||
db.Model(&model.AlertRuleHit{}).Count(&count)
|
|
||||||
if count != 1 {
|
|
||||||
t.Fatalf("清理后应剩 1 条,got %d", count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClearAlertCooldown(t *testing.T) {
|
|
||||||
svc := NewLogEventService(nil)
|
|
||||||
rule1 := model.AlertRule{ID: 1, Threshold: 2, WindowMinutes: 10}
|
|
||||||
rule2 := model.AlertRule{ID: 2, Threshold: 2, WindowMinutes: 10}
|
|
||||||
if !svc.alertCooldownPass(rule1) || !svc.alertCooldownPass(rule2) {
|
|
||||||
t.Fatal("首次命中应通过冷却检查")
|
|
||||||
}
|
|
||||||
|
|
||||||
svc.ClearAlertCooldown([]uint{rule1.ID})
|
|
||||||
if !svc.alertCooldownPass(rule1) {
|
|
||||||
t.Fatal("已清理规则应重新通过冷却检查")
|
|
||||||
}
|
|
||||||
if svc.alertCooldownPass(rule2) {
|
|
||||||
t.Fatal("未清理规则不应通过冷却检查")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecordAlertHitRejectsMissingRefs(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
deleteRule bool
|
|
||||||
}{
|
|
||||||
{name: "规则已删除", deleteRule: true},
|
|
||||||
{name: "事件已删除"},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
testRecordAlertHitMissingRef(t, tt.deleteRule)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testRecordAlertHitMissingRef(t *testing.T, deleteRule bool) {
|
|
||||||
t.Helper()
|
|
||||||
svc, db, cfgID := newLogEventEnv(t)
|
|
||||||
rule := model.AlertRule{Name: "r", OciConfigID: cfgID, Threshold: 2, WindowMinutes: 5}
|
|
||||||
event := model.LogEvent{OciConfigID: cfgID, MessageID: "m"}
|
|
||||||
if err := db.Create(&rule).Error; err != nil {
|
|
||||||
t.Fatalf("create rule: %v", err)
|
|
||||||
}
|
|
||||||
if err := db.Create(&event).Error; err != nil {
|
|
||||||
t.Fatalf("create event: %v", err)
|
|
||||||
}
|
|
||||||
var err error
|
|
||||||
if deleteRule {
|
|
||||||
err = db.Delete(&rule).Error
|
|
||||||
} else {
|
|
||||||
err = db.Delete(&event).Error
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("delete reference: %v", err)
|
|
||||||
}
|
|
||||||
if count, ok := svc.recordAlertHit(context.Background(), rule, &event); ok || count != 0 {
|
|
||||||
t.Fatalf("record missing refs = (%d,%v), want (0,false)", count, ok)
|
|
||||||
}
|
|
||||||
var hits int64
|
|
||||||
db.Model(&model.AlertRuleHit{}).Count(&hits)
|
|
||||||
if hits != 0 {
|
|
||||||
t.Fatalf("orphan alert hits = %d, want 0", hits)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,528 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"oci-portal/internal/aiwire"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Anthropic Messages ↔ OCI OpenAI 兼容面(/actions/v1/responses)直通转换。
|
||||||
|
// typed chat 面剔除后 Messages 入口的唯一上游通路。语义损失(README 已披露):
|
||||||
|
// stop_sequences / top_k / metadata / thinking 无对应字段,忽略;上游 reasoning
|
||||||
|
// 输出项与增量事件丢弃(Anthropic thinking 块含签名语义,不伪造)。
|
||||||
|
|
||||||
|
// ErrAiUnsupportedBlock 表示请求含网关无法承接的内容块(仅支持文本/图片/工具块)。
|
||||||
|
var ErrAiUnsupportedBlock = fmt.Errorf("暂不支持文本与图片以外的内容块")
|
||||||
|
|
||||||
|
// AnthropicToResponsesBody 把 Messages 请求转为直通 body(强制 store:false)。
|
||||||
|
func AnthropicToResponsesBody(req aiwire.MessagesRequest) ([]byte, error) {
|
||||||
|
input, err := anthInputItems(req.Messages)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body := map[string]any{"model": req.Model, "max_output_tokens": req.MaxTokens,
|
||||||
|
"input": input, "store": false}
|
||||||
|
if sys := req.SystemText(); sys != "" {
|
||||||
|
body["instructions"] = sys
|
||||||
|
}
|
||||||
|
if req.Temperature != nil {
|
||||||
|
body["temperature"] = *req.Temperature
|
||||||
|
}
|
||||||
|
if req.TopP != nil {
|
||||||
|
body["top_p"] = *req.TopP
|
||||||
|
}
|
||||||
|
if tools := anthRespTools(req.Tools); tools != nil {
|
||||||
|
body["tools"] = tools
|
||||||
|
}
|
||||||
|
if tc := anthRespToolChoice(req.ToolChoice); tc != nil {
|
||||||
|
body["tool_choice"] = tc
|
||||||
|
}
|
||||||
|
if req.OutputConfig != nil && req.OutputConfig.Effort != "" {
|
||||||
|
body["reasoning"] = map[string]string{"effort": strings.ToLower(req.OutputConfig.Effort)}
|
||||||
|
}
|
||||||
|
if req.Stream {
|
||||||
|
body["stream"] = true
|
||||||
|
}
|
||||||
|
return json.Marshal(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// anthInputItems 把消息序列展开为 Responses input 项:tool_use / tool_result 为
|
||||||
|
// 独立 function_call / function_call_output 项,其余聚合为 message 项;遇独立项时
|
||||||
|
// 先冲刷已聚合部件,保持块间相对顺序。
|
||||||
|
func anthInputItems(messages []aiwire.AnthMessage) ([]any, error) {
|
||||||
|
var items []any
|
||||||
|
for _, m := range messages {
|
||||||
|
var parts []map[string]any
|
||||||
|
flush := func() {
|
||||||
|
if len(parts) > 0 {
|
||||||
|
items = append(items, map[string]any{"role": m.Role, "content": parts})
|
||||||
|
parts = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, b := range m.Content.AllBlocks() {
|
||||||
|
item, part, err := anthBlockItem(m.Role, b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if part != nil {
|
||||||
|
parts = append(parts, part)
|
||||||
|
}
|
||||||
|
if item != nil {
|
||||||
|
flush()
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// anthBlockItem 把单个内容块转为独立项或消息部件(thinking 忽略,未知块拒绝)。
|
||||||
|
func anthBlockItem(role string, b aiwire.AnthBlock) (item, part map[string]any, err error) {
|
||||||
|
switch b.Type {
|
||||||
|
case "text":
|
||||||
|
return nil, respTextPart(role, b.Text), nil
|
||||||
|
case "image":
|
||||||
|
part, err = anthImageInput(b.Source)
|
||||||
|
return nil, part, err
|
||||||
|
case "tool_use":
|
||||||
|
return map[string]any{"type": "function_call", "call_id": b.ID,
|
||||||
|
"name": b.Name, "arguments": string(b.Input)}, nil, nil
|
||||||
|
case "tool_result":
|
||||||
|
return map[string]any{"type": "function_call_output",
|
||||||
|
"call_id": b.ToolUseID, "output": b.ResultText()}, nil, nil
|
||||||
|
case "thinking", "redacted_thinking":
|
||||||
|
return nil, nil, nil
|
||||||
|
default:
|
||||||
|
return nil, nil, ErrAiUnsupportedBlock
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// respTextPart 按角色选择 Responses 文本部件类型(assistant 历史为 output_text);
|
||||||
|
// Anthropic 与 Chat Completions 两条转换链共用。
|
||||||
|
func respTextPart(role, text string) map[string]any {
|
||||||
|
if role == "assistant" {
|
||||||
|
return map[string]any{"type": "output_text", "text": text}
|
||||||
|
}
|
||||||
|
return map[string]any{"type": "input_text", "text": text}
|
||||||
|
}
|
||||||
|
|
||||||
|
// anthImageInput 把 Anthropic image source 转为 Responses input_image 部件。
|
||||||
|
func anthImageInput(source json.RawMessage) (map[string]any, error) {
|
||||||
|
var src struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
MediaType string `json:"media_type"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(source, &src); err != nil {
|
||||||
|
return nil, fmt.Errorf("image source 解析失败: %w", err)
|
||||||
|
}
|
||||||
|
switch src.Type {
|
||||||
|
case "base64":
|
||||||
|
if src.MediaType == "" || src.Data == "" {
|
||||||
|
return nil, fmt.Errorf("image source 缺少 media_type 或 data")
|
||||||
|
}
|
||||||
|
return map[string]any{"type": "input_image",
|
||||||
|
"image_url": "data:" + src.MediaType + ";base64," + src.Data}, nil
|
||||||
|
case "url":
|
||||||
|
if src.URL == "" {
|
||||||
|
return nil, fmt.Errorf("image source 缺少 url")
|
||||||
|
}
|
||||||
|
return map[string]any{"type": "input_image", "image_url": src.URL}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("不支持的 image source 类型 %q", src.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
func anthRespTools(tools []aiwire.AnthTool) []map[string]any {
|
||||||
|
if len(tools) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]map[string]any, 0, len(tools))
|
||||||
|
for _, t := range tools {
|
||||||
|
out = append(out, map[string]any{"type": "function", "name": t.Name,
|
||||||
|
"description": t.Description, "parameters": t.InputSchema})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// anthRespToolChoice 映射 {type:auto|any|tool|none,name} → Responses 形态。
|
||||||
|
func anthRespToolChoice(raw json.RawMessage) any {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var tc struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &tc) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch tc.Type {
|
||||||
|
case "auto":
|
||||||
|
return "auto"
|
||||||
|
case "any":
|
||||||
|
return "required"
|
||||||
|
case "none":
|
||||||
|
return "none"
|
||||||
|
case "tool":
|
||||||
|
return map[string]string{"type": "function", "name": tc.Name}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// respPayload 是直通响应中本转换关心的子集(未知字段忽略)。
|
||||||
|
type respPayload struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
IncompleteDetails *respIncomplete `json:"incomplete_details"`
|
||||||
|
Output []respOutputItem `json:"output"`
|
||||||
|
Usage *aiwire.RespUsage `json:"usage"`
|
||||||
|
Error *map[string]string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type respIncomplete struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type respOutputItem struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
CallID string `json:"call_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments string `json:"arguments"`
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResponsesToAnthropic 把直通非流式响应转为 Anthropic Messages 响应。
|
||||||
|
func ResponsesToAnthropic(payload []byte, msgID string) (*aiwire.MessagesResponse, error) {
|
||||||
|
var resp respPayload
|
||||||
|
if err := json.Unmarshal(payload, &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析上游响应: %w", err)
|
||||||
|
}
|
||||||
|
out := &aiwire.MessagesResponse{ID: msgID, Type: "message", Role: "assistant",
|
||||||
|
Model: resp.Model, Content: []aiwire.AnthBlock{}, StopReason: "end_turn"}
|
||||||
|
for _, item := range resp.Output {
|
||||||
|
switch item.Type {
|
||||||
|
case "message":
|
||||||
|
for _, part := range item.Content {
|
||||||
|
if part.Type == "output_text" && part.Text != "" {
|
||||||
|
out.Content = append(out.Content, aiwire.AnthBlock{Type: "text", Text: part.Text})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "function_call":
|
||||||
|
out.Content = append(out.Content, aiwire.AnthBlock{Type: "tool_use", ID: item.CallID,
|
||||||
|
Name: item.Name, Input: argsToJSON(item.Arguments)})
|
||||||
|
out.StopReason = "tool_use"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if respTruncated(&resp) {
|
||||||
|
out.StopReason = "max_tokens"
|
||||||
|
}
|
||||||
|
out.Usage = anthUsageFromResp(resp.Usage)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func anthUsageFromResp(u *aiwire.RespUsage) aiwire.AnthUsage {
|
||||||
|
if u == nil {
|
||||||
|
return aiwire.AnthUsage{}
|
||||||
|
}
|
||||||
|
return aiwire.AnthUsage{InputTokens: u.InputTokens, OutputTokens: u.OutputTokens,
|
||||||
|
CacheReadInputTokens: u.InputTokensDetails.CachedTokens}
|
||||||
|
}
|
||||||
|
|
||||||
|
// argsToJSON 保证 tool_use.input 是合法 JSON 对象(模型可能产出非法片段)。
|
||||||
|
func argsToJSON(args string) json.RawMessage {
|
||||||
|
trimmed := strings.TrimSpace(args)
|
||||||
|
if trimmed == "" {
|
||||||
|
return json.RawMessage(`{}`)
|
||||||
|
}
|
||||||
|
if json.Valid([]byte(trimmed)) {
|
||||||
|
return json.RawMessage(trimmed)
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(map[string]string{"_raw": args})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Anthropic 流式桥:Responses SSE 事件 → Anthropic 事件序列 ----
|
||||||
|
|
||||||
|
// AnthEvent 是一条待写出的 Anthropic SSE 事件。
|
||||||
|
type AnthEvent struct {
|
||||||
|
Event string
|
||||||
|
Data any
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthRespBridge 把直通 SSE 事件流桥接为 Anthropic 事件序列:
|
||||||
|
// message_start → content_block_start/delta/stop(text 与 tool_use 分块)→ message_delta → message_stop。
|
||||||
|
// reasoning 系列事件丢弃;上游 error / response.failed 转 Anthropic error 事件透传。
|
||||||
|
type AnthRespBridge struct {
|
||||||
|
id, model string
|
||||||
|
started bool
|
||||||
|
blockOpen bool
|
||||||
|
blockIsTool bool
|
||||||
|
blockIndex int
|
||||||
|
stopReason string
|
||||||
|
usage aiwire.AnthUsage
|
||||||
|
// sawTerminal 标记收到过终态事件(completed/incomplete/failed);
|
||||||
|
// 上游流提前 EOF 时据此发 error 事件而非伪装正常结束
|
||||||
|
sawTerminal bool
|
||||||
|
// errMsg 记录上游错误消息,非空即本流已失败(供调用日志)
|
||||||
|
errMsg string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAnthRespBridge 构造桥;id 为响应消息 ID。
|
||||||
|
func NewAnthRespBridge(id, model string) *AnthRespBridge {
|
||||||
|
return &AnthRespBridge{id: id, model: model, blockIndex: -1, stopReason: "end_turn"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// respStreamEvent 是直通 SSE data JSON 中桥关心的子集。
|
||||||
|
type respStreamEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Delta string `json:"delta"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Item *respOutputItem `json:"item"`
|
||||||
|
Response *respPayload `json:"response"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed 消费一行 SSE data JSON,返回应立即写出的事件。
|
||||||
|
func (st *AnthRespBridge) Feed(data []byte) []AnthEvent {
|
||||||
|
var ev respStreamEvent
|
||||||
|
if json.Unmarshal(data, &ev) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ev.Type == "error" || ev.Type == "response.failed" {
|
||||||
|
return st.failWith(ev)
|
||||||
|
}
|
||||||
|
// message_start 延迟到首个可见输出事件:created / reasoning 阶段不向客户端
|
||||||
|
// 写任何字节,上游此段断流时 handler 才有降级非流式重做的无感窗口
|
||||||
|
var events []AnthEvent
|
||||||
|
switch ev.Type {
|
||||||
|
case "response.output_item.added":
|
||||||
|
if ev.Item != nil && ev.Item.Type == "function_call" {
|
||||||
|
events = append(events, st.ensureStarted()...)
|
||||||
|
events = append(events, st.openBlock(true, ev.Item.CallID, ev.Item.Name)...)
|
||||||
|
st.stopReason = "tool_use"
|
||||||
|
}
|
||||||
|
case "response.output_text.delta":
|
||||||
|
events = append(events, st.ensureStarted()...)
|
||||||
|
events = append(events, st.textDelta(ev.Delta)...)
|
||||||
|
case "response.function_call_arguments.delta":
|
||||||
|
events = append(events, st.argsDelta(ev.Delta)...)
|
||||||
|
case "response.completed", "response.incomplete":
|
||||||
|
st.sawTerminal = true
|
||||||
|
st.finishFrom(ev.Response)
|
||||||
|
}
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureStarted 在首个可见输出事件前补发 message_start(仅一次)。
|
||||||
|
func (st *AnthRespBridge) ensureStarted() []AnthEvent {
|
||||||
|
if st.started {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
st.started = true
|
||||||
|
return []AnthEvent{st.startEvent()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// failWith 记录上游错误并产出 Anthropic error 事件(每流至多一次)。
|
||||||
|
func (st *AnthRespBridge) failWith(ev respStreamEvent) []AnthEvent {
|
||||||
|
if st.errMsg != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
st.sawTerminal = true
|
||||||
|
msg := ev.Message
|
||||||
|
if ev.Type == "response.failed" {
|
||||||
|
st.finishFrom(ev.Response)
|
||||||
|
if m := respErrorMsg(ev.Response); m != "" {
|
||||||
|
msg = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
msg = "上游返回错误事件 " + ev.Type
|
||||||
|
}
|
||||||
|
st.errMsg = msg
|
||||||
|
return []AnthEvent{st.errorEvent()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// respErrorMsg 提取 response.failed 载荷中的错误消息。
|
||||||
|
func respErrorMsg(resp *respPayload) string {
|
||||||
|
if resp == nil || resp.Error == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return (*resp.Error)["message"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorEvent 按 Anthropic 流式协议产出 error 事件。
|
||||||
|
func (st *AnthRespBridge) errorEvent() AnthEvent {
|
||||||
|
return AnthEvent{Event: "error", Data: map[string]any{
|
||||||
|
"type": "error",
|
||||||
|
"error": map[string]string{"type": "api_error", "message": st.errMsg},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Err 返回上游错误消息;空串表示流正常(供调用日志)。
|
||||||
|
func (st *AnthRespBridge) Err() string { return st.errMsg }
|
||||||
|
|
||||||
|
// SawTerminal 报告是否收到过终态事件;false 即上游流提前终止。
|
||||||
|
func (st *AnthRespBridge) SawTerminal() bool { return st.sawTerminal }
|
||||||
|
|
||||||
|
// AnthMessageEvents 把完整 Messages 响应展开为标准事件序列,
|
||||||
|
// 供流式上游断流后的非流式降级结果推送(客户端协议不变)。
|
||||||
|
func AnthMessageEvents(m *aiwire.MessagesResponse) []AnthEvent {
|
||||||
|
events := []AnthEvent{{Event: "message_start", Data: map[string]any{
|
||||||
|
"type": "message_start",
|
||||||
|
"message": map[string]any{
|
||||||
|
"id": m.ID, "type": "message", "role": m.Role, "model": m.Model,
|
||||||
|
"content": []any{}, "stop_reason": nil,
|
||||||
|
"usage": map[string]int{"input_tokens": 0, "output_tokens": 0},
|
||||||
|
},
|
||||||
|
}}}
|
||||||
|
for i, b := range m.Content {
|
||||||
|
events = append(events, anthBlockEvents(i, b)...)
|
||||||
|
}
|
||||||
|
usage := map[string]int{"input_tokens": m.Usage.InputTokens, "output_tokens": m.Usage.OutputTokens}
|
||||||
|
if m.Usage.CacheReadInputTokens > 0 {
|
||||||
|
usage["cache_read_input_tokens"] = m.Usage.CacheReadInputTokens
|
||||||
|
}
|
||||||
|
events = append(events,
|
||||||
|
AnthEvent{Event: "message_delta", Data: map[string]any{
|
||||||
|
"type": "message_delta",
|
||||||
|
"delta": map[string]any{"stop_reason": m.StopReason, "stop_sequence": nil},
|
||||||
|
"usage": usage,
|
||||||
|
}},
|
||||||
|
AnthEvent{Event: "message_stop", Data: map[string]any{"type": "message_stop"}})
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
// anthBlockEvents 把一个内容块展开为 start / delta / stop 三事件。
|
||||||
|
func anthBlockEvents(index int, b aiwire.AnthBlock) []AnthEvent {
|
||||||
|
var start, delta map[string]any
|
||||||
|
if b.Type == "tool_use" {
|
||||||
|
start = map[string]any{"type": "tool_use", "id": b.ID, "name": b.Name, "input": map[string]any{}}
|
||||||
|
delta = map[string]any{"type": "input_json_delta", "partial_json": string(b.Input)}
|
||||||
|
} else {
|
||||||
|
start = map[string]any{"type": "text", "text": ""}
|
||||||
|
delta = map[string]any{"type": "text_delta", "text": b.Text}
|
||||||
|
}
|
||||||
|
return []AnthEvent{
|
||||||
|
{Event: "content_block_start", Data: map[string]any{
|
||||||
|
"type": "content_block_start", "index": index, "content_block": start}},
|
||||||
|
{Event: "content_block_delta", Data: map[string]any{
|
||||||
|
"type": "content_block_delta", "index": index, "delta": delta}},
|
||||||
|
{Event: "content_block_stop", Data: map[string]any{
|
||||||
|
"type": "content_block_stop", "index": index}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *AnthRespBridge) textDelta(delta string) []AnthEvent {
|
||||||
|
var events []AnthEvent
|
||||||
|
if !st.blockOpen || st.blockIsTool {
|
||||||
|
events = append(events, st.openBlock(false, "", "")...)
|
||||||
|
}
|
||||||
|
events = append(events, AnthEvent{Event: "content_block_delta", Data: map[string]any{
|
||||||
|
"type": "content_block_delta", "index": st.blockIndex,
|
||||||
|
"delta": map[string]string{"type": "text_delta", "text": delta},
|
||||||
|
}})
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *AnthRespBridge) argsDelta(delta string) []AnthEvent {
|
||||||
|
if !st.blockOpen || !st.blockIsTool {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []AnthEvent{{Event: "content_block_delta", Data: map[string]any{
|
||||||
|
"type": "content_block_delta", "index": st.blockIndex,
|
||||||
|
"delta": map[string]string{"type": "input_json_delta", "partial_json": delta},
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishFrom 记录终态:usage 与 stop_reason(max_output_tokens 截断 → max_tokens)。
|
||||||
|
func (st *AnthRespBridge) finishFrom(resp *respPayload) {
|
||||||
|
if resp == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st.usage = anthUsageFromResp(resp.Usage)
|
||||||
|
if respTruncated(resp) {
|
||||||
|
st.stopReason = "max_tokens"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *AnthRespBridge) startEvent() AnthEvent {
|
||||||
|
return AnthEvent{Event: "message_start", Data: map[string]any{
|
||||||
|
"type": "message_start",
|
||||||
|
"message": map[string]any{
|
||||||
|
"id": st.id, "type": "message", "role": "assistant", "model": st.model,
|
||||||
|
"content": []any{}, "stop_reason": nil,
|
||||||
|
"usage": map[string]int{"input_tokens": 0, "output_tokens": 0},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openBlock 关闭当前块并打开新块(text 或 tool_use)。
|
||||||
|
func (st *AnthRespBridge) openBlock(isTool bool, toolID, toolName string) []AnthEvent {
|
||||||
|
var events []AnthEvent
|
||||||
|
if st.blockOpen {
|
||||||
|
events = append(events, st.closeBlockEvent())
|
||||||
|
}
|
||||||
|
st.blockOpen, st.blockIsTool = true, isTool
|
||||||
|
st.blockIndex++
|
||||||
|
block := map[string]any{"type": "text", "text": ""}
|
||||||
|
if isTool {
|
||||||
|
block = map[string]any{"type": "tool_use", "id": toolID, "name": toolName, "input": map[string]any{}}
|
||||||
|
}
|
||||||
|
events = append(events, AnthEvent{Event: "content_block_start", Data: map[string]any{
|
||||||
|
"type": "content_block_start", "index": st.blockIndex, "content_block": block,
|
||||||
|
}})
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *AnthRespBridge) closeBlockEvent() AnthEvent {
|
||||||
|
return AnthEvent{Event: "content_block_stop", Data: map[string]any{
|
||||||
|
"type": "content_block_stop", "index": st.blockIndex,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish 在上游流结束后收尾:关块 → message_delta(stop_reason+usage)→ message_stop。
|
||||||
|
// 已发过 error 事件的流不再补终态;未见终态事件即 EOF 视为上游提前终止,
|
||||||
|
// 发 error 事件而非伪装正常结束(否则客户端拿到"成功的空消息")。
|
||||||
|
func (st *AnthRespBridge) Finish() []AnthEvent {
|
||||||
|
if st.errMsg != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !st.sawTerminal {
|
||||||
|
st.errMsg = "上游流提前终止,未返回终态事件"
|
||||||
|
return []AnthEvent{st.errorEvent()}
|
||||||
|
}
|
||||||
|
var events []AnthEvent
|
||||||
|
if !st.started {
|
||||||
|
st.started = true
|
||||||
|
events = append(events, st.startEvent())
|
||||||
|
}
|
||||||
|
if st.blockOpen {
|
||||||
|
events = append(events, st.closeBlockEvent())
|
||||||
|
st.blockOpen = false
|
||||||
|
}
|
||||||
|
usage := map[string]int{"output_tokens": st.usage.OutputTokens}
|
||||||
|
if st.usage.InputTokens > 0 {
|
||||||
|
usage["input_tokens"] = st.usage.InputTokens
|
||||||
|
}
|
||||||
|
if st.usage.CacheReadInputTokens > 0 {
|
||||||
|
usage["cache_read_input_tokens"] = st.usage.CacheReadInputTokens
|
||||||
|
}
|
||||||
|
events = append(events,
|
||||||
|
AnthEvent{Event: "message_delta", Data: map[string]any{
|
||||||
|
"type": "message_delta",
|
||||||
|
"delta": map[string]any{"stop_reason": st.stopReason, "stop_sequence": nil},
|
||||||
|
"usage": usage,
|
||||||
|
}},
|
||||||
|
AnthEvent{Event: "message_stop", Data: map[string]any{"type": "message_stop"}},
|
||||||
|
)
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage 返回聚合到的用量(供调用日志)。
|
||||||
|
func (st *AnthRespBridge) Usage() aiwire.AnthUsage { return st.usage }
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"oci-portal/internal/aiwire"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mustAnthReq(t *testing.T, raw string) aiwire.MessagesRequest {
|
||||||
|
t.Helper()
|
||||||
|
var req aiwire.MessagesRequest
|
||||||
|
if err := json.Unmarshal([]byte(raw), &req); err != nil {
|
||||||
|
t.Fatalf("解析请求: %v", err)
|
||||||
|
}
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnthropicToResponsesBody 断言 system/消息/工具/effort 的直通装配与 store 强制。
|
||||||
|
func TestAnthropicToResponsesBody(t *testing.T) {
|
||||||
|
req := mustAnthReq(t, `{
|
||||||
|
"model": "xai.grok-4.3", "max_tokens": 128, "system": "你是助手", "stream": true,
|
||||||
|
"temperature": 0.5,
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "东京天气?"},
|
||||||
|
{"role": "assistant", "content": [
|
||||||
|
{"type": "text", "text": "查询中"},
|
||||||
|
{"type": "tool_use", "id": "t1", "name": "get_weather", "input": {"city": "东京"}}
|
||||||
|
]},
|
||||||
|
{"role": "user", "content": [
|
||||||
|
{"type": "tool_result", "tool_use_id": "t1", "content": "晴 25 度"},
|
||||||
|
{"type": "text", "text": "继续"}
|
||||||
|
]}
|
||||||
|
],
|
||||||
|
"tools": [{"name": "get_weather", "description": "查天气", "input_schema": {"type": "object"}}],
|
||||||
|
"tool_choice": {"type": "any"},
|
||||||
|
"output_config": {"effort": "HIGH"}
|
||||||
|
}`)
|
||||||
|
payload, err := AnthropicToResponsesBody(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AnthropicToResponsesBody: %v", err)
|
||||||
|
}
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(payload, &body); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if body["model"] != "xai.grok-4.3" || body["max_output_tokens"] != float64(128) ||
|
||||||
|
body["instructions"] != "你是助手" || body["store"] != false || body["stream"] != true {
|
||||||
|
t.Fatalf("顶层字段装配错误: %v", body)
|
||||||
|
}
|
||||||
|
if body["tool_choice"] != "required" {
|
||||||
|
t.Fatalf("tool_choice = %v, want required", body["tool_choice"])
|
||||||
|
}
|
||||||
|
reasoning, _ := body["reasoning"].(map[string]any)
|
||||||
|
if reasoning["effort"] != "high" {
|
||||||
|
t.Fatalf("effort = %v, want high(小写透传)", reasoning)
|
||||||
|
}
|
||||||
|
input, _ := body["input"].([]any)
|
||||||
|
// user 消息、assistant 文本消息、function_call、function_call_output、末条 user 文本
|
||||||
|
if len(input) != 5 {
|
||||||
|
t.Fatalf("input 项数 = %d, want 5: %s", len(input), payload)
|
||||||
|
}
|
||||||
|
kinds := make([]string, 0, len(input))
|
||||||
|
for _, it := range input {
|
||||||
|
m := it.(map[string]any)
|
||||||
|
if ty, ok := m["type"].(string); ok {
|
||||||
|
kinds = append(kinds, ty)
|
||||||
|
} else {
|
||||||
|
kinds = append(kinds, "message/"+m["role"].(string))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want := "message/user,message/assistant,function_call,function_call_output,message/user"
|
||||||
|
if got := strings.Join(kinds, ","); got != want {
|
||||||
|
t.Fatalf("input 顺序 = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(payload), `"output_text"`) {
|
||||||
|
t.Fatalf("assistant 历史文本应为 output_text 部件: %s", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnthropicToResponsesBodyRejects 断言不支持的内容块拒绝与图片装配。
|
||||||
|
func TestAnthropicToResponsesBodyRejects(t *testing.T) {
|
||||||
|
bad := mustAnthReq(t, `{"model":"m","max_tokens":8,"messages":[
|
||||||
|
{"role":"user","content":[{"type":"document","source":{}}]}]}`)
|
||||||
|
if _, err := AnthropicToResponsesBody(bad); err == nil {
|
||||||
|
t.Fatal("document 块应拒绝")
|
||||||
|
}
|
||||||
|
img := mustAnthReq(t, `{"model":"m","max_tokens":8,"messages":[
|
||||||
|
{"role":"user","content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"QUJD"}}]}]}`)
|
||||||
|
payload, err := AnthropicToResponsesBody(img)
|
||||||
|
if err != nil || !strings.Contains(string(payload), "data:image/png;base64,QUJD") {
|
||||||
|
t.Fatalf("图片应转 data URI: %v %s", err, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResponsesToAnthropic 断言输出块、stop_reason 与 usage 的回转。
|
||||||
|
func TestResponsesToAnthropic(t *testing.T) {
|
||||||
|
payload := []byte(`{"model":"xai.grok-4.3","status":"completed","output":[
|
||||||
|
{"type":"reasoning","summary":[]},
|
||||||
|
{"type":"message","content":[{"type":"output_text","text":"你好"}]},
|
||||||
|
{"type":"function_call","call_id":"c1","name":"get_weather","arguments":"{\"city\":\"东京\"}"}],
|
||||||
|
"usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15,"input_tokens_details":{"cached_tokens":4}}}`)
|
||||||
|
out, err := ResponsesToAnthropic(payload, "msg_1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResponsesToAnthropic: %v", err)
|
||||||
|
}
|
||||||
|
if len(out.Content) != 2 || out.Content[0].Type != "text" || out.Content[0].Text != "你好" {
|
||||||
|
t.Fatalf("content 装配错误: %+v", out.Content)
|
||||||
|
}
|
||||||
|
if out.Content[1].Type != "tool_use" || out.Content[1].ID != "c1" || string(out.Content[1].Input) != `{"city":"东京"}` {
|
||||||
|
t.Fatalf("tool_use 装配错误: %+v", out.Content[1])
|
||||||
|
}
|
||||||
|
if out.StopReason != "tool_use" {
|
||||||
|
t.Fatalf("stop_reason = %s, want tool_use", out.StopReason)
|
||||||
|
}
|
||||||
|
if out.Usage.InputTokens != 10 || out.Usage.OutputTokens != 5 || out.Usage.CacheReadInputTokens != 4 {
|
||||||
|
t.Fatalf("usage = %+v", out.Usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
trunc := []byte(`{"model":"m","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},
|
||||||
|
"output":[{"type":"message","content":[{"type":"output_text","text":"半"}]}]}`)
|
||||||
|
out2, err := ResponsesToAnthropic(trunc, "msg_2")
|
||||||
|
if err != nil || out2.StopReason != "max_tokens" {
|
||||||
|
t.Fatalf("截断 stop_reason = %v, %v", out2, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bridgeEventTypes(events []AnthEvent) string {
|
||||||
|
kinds := make([]string, 0, len(events))
|
||||||
|
for _, ev := range events {
|
||||||
|
kinds = append(kinds, ev.Event)
|
||||||
|
}
|
||||||
|
return strings.Join(kinds, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnthRespBridge 断言流桥:文本增量、工具调用切块、reasoning 丢弃与收尾事件。
|
||||||
|
func TestAnthRespBridge(t *testing.T) {
|
||||||
|
st := NewAnthRespBridge("msg_1", "m1")
|
||||||
|
var events []AnthEvent
|
||||||
|
feed := func(lines ...string) {
|
||||||
|
for _, l := range lines {
|
||||||
|
events = append(events, st.Feed([]byte(l))...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
feed(`{"type":"response.created","response":{"model":"m1"}}`,
|
||||||
|
`{"type":"response.reasoning_text.delta","delta":"思考中"}`,
|
||||||
|
`{"type":"response.output_text.delta","delta":"你"}`,
|
||||||
|
`{"type":"response.output_text.delta","delta":"好"}`,
|
||||||
|
`{"type":"response.output_item.added","item":{"type":"function_call","call_id":"c1","name":"f"}}`,
|
||||||
|
`{"type":"response.function_call_arguments.delta","delta":"{\"a\":1}"}`,
|
||||||
|
`{"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":8,"output_tokens":4}}}`)
|
||||||
|
events = append(events, st.Finish()...)
|
||||||
|
|
||||||
|
got := bridgeEventTypes(events)
|
||||||
|
want := "message_start,content_block_start,content_block_delta,content_block_delta," +
|
||||||
|
"content_block_stop,content_block_start,content_block_delta,content_block_stop," +
|
||||||
|
"message_delta,message_stop"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("事件序列:\n got %s\nwant %s", got, want)
|
||||||
|
}
|
||||||
|
if st.Usage().InputTokens != 8 || st.Usage().OutputTokens != 4 {
|
||||||
|
t.Fatalf("usage = %+v", st.Usage())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnthRespBridgeFailure 断言异常流的错误透传:上游 error / response.failed
|
||||||
|
// 事件转 Anthropic error 事件,提前 EOF(未见终态)不再伪装正常结束。
|
||||||
|
func TestAnthRespBridgeFailure(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
lines []string
|
||||||
|
wantKinds string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "上游 error 事件透传",
|
||||||
|
lines: []string{`{"type":"error","message":"model overloaded"}`},
|
||||||
|
wantKinds: "error",
|
||||||
|
wantErr: "model overloaded",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "response.failed 提取错误消息",
|
||||||
|
lines: []string{
|
||||||
|
`{"type":"response.output_text.delta","delta":"你"}`,
|
||||||
|
`{"type":"response.failed","response":{"status":"failed","error":{"message":"content filtered"}}}`,
|
||||||
|
},
|
||||||
|
wantKinds: "message_start,content_block_start,content_block_delta,error",
|
||||||
|
wantErr: "content filtered",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "空流提前终止",
|
||||||
|
lines: nil,
|
||||||
|
wantKinds: "error",
|
||||||
|
wantErr: "上游流提前终止,未返回终态事件",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "输出中途 EOF 无终态",
|
||||||
|
lines: []string{
|
||||||
|
`{"type":"response.output_text.delta","delta":"你"}`,
|
||||||
|
},
|
||||||
|
wantKinds: "message_start,content_block_start,content_block_delta,error",
|
||||||
|
wantErr: "上游流提前终止,未返回终态事件",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
st := NewAnthRespBridge("msg_1", "m1")
|
||||||
|
var events []AnthEvent
|
||||||
|
for _, l := range tc.lines {
|
||||||
|
events = append(events, st.Feed([]byte(l))...)
|
||||||
|
}
|
||||||
|
events = append(events, st.Finish()...)
|
||||||
|
if got := bridgeEventTypes(events); got != tc.wantKinds {
|
||||||
|
t.Fatalf("事件序列 = %s, want %s", got, tc.wantKinds)
|
||||||
|
}
|
||||||
|
if st.Err() != tc.wantErr {
|
||||||
|
t.Fatalf("Err() = %q, want %q", st.Err(), tc.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRespStreamErrorMsg 断言直通流错误事件消息提取。
|
||||||
|
func TestRespStreamErrorMsg(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
data string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "error 事件", data: `{"type":"error","message":"boom"}`, want: "boom"},
|
||||||
|
{name: "error 无消息用占位", data: `{"type":"error"}`, want: "上游返回错误事件 error"},
|
||||||
|
{name: "failed 事件", data: `{"type":"response.failed","response":{"error":{"message":"bad"}}}`, want: "bad"},
|
||||||
|
{name: "正常事件返回空", data: `{"type":"response.completed","response":{}}`, want: ""},
|
||||||
|
{name: "非 JSON 返回空", data: `<html>`, want: ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := RespStreamErrorMsg([]byte(tc.data)); got != tc.want {
|
||||||
|
t.Fatalf("RespStreamErrorMsg = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnthMessageEvents 断言非流式降级结果展开的事件序列与 usage。
|
||||||
|
func TestAnthMessageEvents(t *testing.T) {
|
||||||
|
msg := &aiwire.MessagesResponse{
|
||||||
|
ID: "msg_1", Type: "message", Role: "assistant", Model: "m1",
|
||||||
|
Content: []aiwire.AnthBlock{
|
||||||
|
{Type: "text", Text: "好"},
|
||||||
|
{Type: "tool_use", ID: "c1", Name: "f", Input: json.RawMessage(`{"a":1}`)},
|
||||||
|
},
|
||||||
|
StopReason: "tool_use",
|
||||||
|
Usage: aiwire.AnthUsage{InputTokens: 9, OutputTokens: 3, CacheReadInputTokens: 5},
|
||||||
|
}
|
||||||
|
events := AnthMessageEvents(msg)
|
||||||
|
want := "message_start,content_block_start,content_block_delta,content_block_stop," +
|
||||||
|
"content_block_start,content_block_delta,content_block_stop,message_delta,message_stop"
|
||||||
|
if got := bridgeEventTypes(events); got != want {
|
||||||
|
t.Fatalf("事件序列:\n got %s\nwant %s", got, want)
|
||||||
|
}
|
||||||
|
delta := events[len(events)-2].Data.(map[string]any)
|
||||||
|
usage := delta["usage"].(map[string]int)
|
||||||
|
if usage["input_tokens"] != 9 || usage["output_tokens"] != 3 || usage["cache_read_input_tokens"] != 5 {
|
||||||
|
t.Fatalf("usage = %+v", usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"oci-portal/internal/aiwire"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpenAI Chat Completions ↔ OCI OpenAI 兼容面(/actions/v1/responses)直通转换。
|
||||||
|
// 端点定位 Tier 2 兼容层(承接只会说 CC 的存量客户端),机制与 anthresponses.go
|
||||||
|
// 同构。语义损失(README 披露):stop / seed / n / penalty 等无对应字段,忽略;
|
||||||
|
// 上游 reasoning 输出项与增量事件丢弃;非 function 工具类型拒绝。
|
||||||
|
|
||||||
|
// ChatToResponsesBody 把 Chat Completions 请求转为直通 body(强制 store:false)。
|
||||||
|
func ChatToResponsesBody(req aiwire.ChatRequest) ([]byte, error) {
|
||||||
|
input, instructions, err := chatInputItems(req.Messages)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body := map[string]any{"model": req.Model, "input": input, "store": false}
|
||||||
|
if instructions != "" {
|
||||||
|
body["instructions"] = instructions
|
||||||
|
}
|
||||||
|
chatSampling(body, req)
|
||||||
|
if err := chatBodyTools(body, req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tf := chatTextFormat(req.ResponseFormat); tf != nil {
|
||||||
|
body["text"] = map[string]any{"format": tf}
|
||||||
|
}
|
||||||
|
if req.ReasoningEffort != "" {
|
||||||
|
body["reasoning"] = map[string]string{"effort": strings.ToLower(req.ReasoningEffort)}
|
||||||
|
}
|
||||||
|
if req.Stream {
|
||||||
|
body["stream"] = true
|
||||||
|
}
|
||||||
|
return json.Marshal(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatSampling 装配采样与输出预算;max_completion_tokens 优先于已弃用的 max_tokens。
|
||||||
|
func chatSampling(body map[string]any, req aiwire.ChatRequest) {
|
||||||
|
if req.MaxCompletionTokens != nil {
|
||||||
|
body["max_output_tokens"] = *req.MaxCompletionTokens
|
||||||
|
} else if req.MaxTokens != nil {
|
||||||
|
body["max_output_tokens"] = *req.MaxTokens
|
||||||
|
}
|
||||||
|
if req.Temperature != nil {
|
||||||
|
body["temperature"] = *req.Temperature
|
||||||
|
}
|
||||||
|
if req.TopP != nil {
|
||||||
|
body["top_p"] = *req.TopP
|
||||||
|
}
|
||||||
|
if req.ParallelToolCalls != nil {
|
||||||
|
body["parallel_tool_calls"] = *req.ParallelToolCalls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatBodyTools 装配工具与 tool_choice;非 function 工具类型拒绝
|
||||||
|
// (web_search 等新能力仅在 Responses / Messages 端点供给)。
|
||||||
|
func chatBodyTools(body map[string]any, req aiwire.ChatRequest) error {
|
||||||
|
if len(req.Tools) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tools := make([]map[string]any, 0, len(req.Tools))
|
||||||
|
for _, t := range req.Tools {
|
||||||
|
if t.Type != "function" {
|
||||||
|
return fmt.Errorf("不支持的工具类型 %q:该端点仅支持 function 工具", t.Type)
|
||||||
|
}
|
||||||
|
tools = append(tools, map[string]any{"type": "function", "name": t.Function.Name,
|
||||||
|
"description": t.Function.Description, "parameters": t.Function.Parameters})
|
||||||
|
}
|
||||||
|
body["tools"] = tools
|
||||||
|
if tc := chatRespToolChoice(req.ToolChoice); tc != nil {
|
||||||
|
body["tool_choice"] = tc
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatInputItems 把消息序列展开为 Responses input 项与 instructions:
|
||||||
|
// system/developer 文本聚为 instructions;tool 消息为 function_call_output 项;
|
||||||
|
// 其余消息经 chatMessageItems 展开,保持相对顺序。
|
||||||
|
func chatInputItems(messages []aiwire.ChatMessage) ([]any, string, error) {
|
||||||
|
items := make([]any, 0, len(messages))
|
||||||
|
var sys []string
|
||||||
|
for _, m := range messages {
|
||||||
|
switch m.Role {
|
||||||
|
case "system", "developer":
|
||||||
|
if txt := m.Content.JoinText(); txt != "" {
|
||||||
|
sys = append(sys, txt)
|
||||||
|
}
|
||||||
|
case "tool":
|
||||||
|
items = append(items, map[string]any{"type": "function_call_output",
|
||||||
|
"call_id": m.ToolCallID, "output": m.Content.JoinText()})
|
||||||
|
default:
|
||||||
|
msgItems, err := chatMessageItems(m)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
items = append(items, msgItems...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items, strings.Join(sys, "\n\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatMessageItems 把 user/assistant 消息转为 message 项;assistant 的
|
||||||
|
// tool_calls 追加为独立 function_call 项(内容在前,与原语序一致)。
|
||||||
|
func chatMessageItems(m aiwire.ChatMessage) ([]any, error) {
|
||||||
|
parts, err := chatContentParts(m.Role, m.Content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]any, 0, 1+len(m.ToolCalls))
|
||||||
|
if len(parts) > 0 {
|
||||||
|
items = append(items, map[string]any{"role": m.Role, "content": parts})
|
||||||
|
}
|
||||||
|
for _, tc := range m.ToolCalls {
|
||||||
|
items = append(items, map[string]any{"type": "function_call", "call_id": tc.ID,
|
||||||
|
"name": tc.Function.Name, "arguments": tc.Function.Arguments})
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatContentParts 把消息内容转为 Responses 部件(文本按角色定型,图片转 input_image)。
|
||||||
|
func chatContentParts(role string, c aiwire.Content) ([]map[string]any, error) {
|
||||||
|
if !c.IsArray {
|
||||||
|
if c.Text == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return []map[string]any{respTextPart(role, c.Text)}, nil
|
||||||
|
}
|
||||||
|
parts := make([]map[string]any, 0, len(c.Parts))
|
||||||
|
for _, p := range c.Parts {
|
||||||
|
switch p.Type {
|
||||||
|
case "", "text":
|
||||||
|
parts = append(parts, respTextPart(role, p.Text))
|
||||||
|
case "image_url":
|
||||||
|
if p.ImageURL == nil || p.ImageURL.URL == "" {
|
||||||
|
return nil, fmt.Errorf("image_url 块缺少 url")
|
||||||
|
}
|
||||||
|
// detail 不透传:兼容面对该字段的支持无法证实(实测期间上游视觉请求不稳,
|
||||||
|
// 无从归因),它仅是质量提示,忽略合法且消除一个风险轴(README 披露)
|
||||||
|
parts = append(parts, map[string]any{"type": "input_image", "image_url": p.ImageURL.URL})
|
||||||
|
default:
|
||||||
|
return nil, ErrAiUnsupportedBlock
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatRespToolChoice 映射 tool_choice:string 形态(auto/none/required)原样;
|
||||||
|
// {"type":"function","function":{"name":N}} 拍平为 Responses 具名形态;其余忽略。
|
||||||
|
func chatRespToolChoice(raw json.RawMessage) any {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(raw, &s) == nil {
|
||||||
|
switch s {
|
||||||
|
case "auto", "none", "required":
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var tc struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"function"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &tc) != nil || tc.Type != "function" || tc.Function.Name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return map[string]string{"type": "function", "name": tc.Function.Name}
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatTextFormat 把 response_format 拍平为 text.format(json_schema 提升嵌套字段)。
|
||||||
|
func chatTextFormat(rf *aiwire.ResponseFormat) map[string]any {
|
||||||
|
if rf == nil || rf.Type == "" || rf.Type == "text" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
format := map[string]any{"type": rf.Type}
|
||||||
|
if rf.Type != "json_schema" || len(rf.JSONSchema) == 0 {
|
||||||
|
return format
|
||||||
|
}
|
||||||
|
var js struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Schema json.RawMessage `json:"schema"`
|
||||||
|
Strict *bool `json:"strict"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(rf.JSONSchema, &js) != nil {
|
||||||
|
return format
|
||||||
|
}
|
||||||
|
if js.Name != "" {
|
||||||
|
format["name"] = js.Name
|
||||||
|
}
|
||||||
|
if len(js.Schema) > 0 {
|
||||||
|
format["schema"] = js.Schema
|
||||||
|
}
|
||||||
|
if js.Strict != nil {
|
||||||
|
format["strict"] = *js.Strict
|
||||||
|
}
|
||||||
|
return format
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResponsesToChat 把直通非流式响应转为 Chat Completions 响应(reasoning 项丢弃)。
|
||||||
|
func ResponsesToChat(payload []byte, id string, created int64) (*aiwire.ChatResponse, error) {
|
||||||
|
var resp respPayload
|
||||||
|
if err := json.Unmarshal(payload, &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析上游响应: %w", err)
|
||||||
|
}
|
||||||
|
msg := aiwire.ChatMessage{Role: "assistant"}
|
||||||
|
var text strings.Builder
|
||||||
|
for _, item := range resp.Output {
|
||||||
|
switch item.Type {
|
||||||
|
case "message":
|
||||||
|
for _, part := range item.Content {
|
||||||
|
if part.Type == "output_text" {
|
||||||
|
text.WriteString(part.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "function_call":
|
||||||
|
msg.ToolCalls = append(msg.ToolCalls, aiwire.ToolCall{ID: item.CallID, Type: "function",
|
||||||
|
Function: aiwire.FunctionCall{Name: item.Name, Arguments: item.Arguments}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg.Content = aiwire.NewTextContent(text.String())
|
||||||
|
choice := aiwire.Choice{Index: 0, Message: msg,
|
||||||
|
FinishReason: chatFinishReason(&resp, len(msg.ToolCalls) > 0)}
|
||||||
|
return &aiwire.ChatResponse{ID: id, Object: "chat.completion", Created: created,
|
||||||
|
Model: resp.Model, Choices: []aiwire.Choice{choice}, Usage: usageFromResp(resp.Usage)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatFinishReason 映射终态:截断优先 → length;含工具调用 → tool_calls;默认 stop。
|
||||||
|
func chatFinishReason(resp *respPayload, hasTools bool) string {
|
||||||
|
if respTruncated(resp) {
|
||||||
|
return "length"
|
||||||
|
}
|
||||||
|
if hasTools {
|
||||||
|
return "tool_calls"
|
||||||
|
}
|
||||||
|
return "stop"
|
||||||
|
}
|
||||||
|
|
||||||
|
// respTruncated 报告直通响应是否因 max_output_tokens 截断。
|
||||||
|
func respTruncated(resp *respPayload) bool {
|
||||||
|
return resp != nil && resp.Status == "incomplete" && resp.IncompleteDetails != nil &&
|
||||||
|
resp.IncompleteDetails.Reason == "max_output_tokens"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Chat Completions 流式桥:直通 SSE 事件 → chat.completion.chunk 序列 ----
|
||||||
|
|
||||||
|
// ChatRespBridge 把直通 SSE 事件流桥接为 chunk 序列:首个增量补 role,文本与
|
||||||
|
// 工具增量按 OpenAI 形态输出;reasoning 系列事件丢弃;[DONE] 由传输层写出。
|
||||||
|
type ChatRespBridge struct {
|
||||||
|
id, model string
|
||||||
|
created int64
|
||||||
|
includeUsage bool
|
||||||
|
started bool
|
||||||
|
toolIndex int
|
||||||
|
inTool bool
|
||||||
|
finish string
|
||||||
|
usage *aiwire.Usage
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewChatRespBridge 构造桥;includeUsage 即 stream_options.include_usage。
|
||||||
|
func NewChatRespBridge(id, model string, created int64, includeUsage bool) *ChatRespBridge {
|
||||||
|
return &ChatRespBridge{id: id, model: model, created: created,
|
||||||
|
includeUsage: includeUsage, toolIndex: -1, finish: "stop"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed 消费一行 SSE data JSON,返回应立即写出的 chunk。
|
||||||
|
func (b *ChatRespBridge) Feed(data []byte) []aiwire.ChatChunk {
|
||||||
|
var ev respStreamEvent
|
||||||
|
if json.Unmarshal(data, &ev) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch ev.Type {
|
||||||
|
case "response.output_text.delta":
|
||||||
|
b.inTool = false
|
||||||
|
return []aiwire.ChatChunk{b.chunk(b.deltaWithRole(aiwire.Delta{Content: ev.Delta}), nil)}
|
||||||
|
case "response.output_item.added":
|
||||||
|
return b.toolOpen(ev.Item)
|
||||||
|
case "response.function_call_arguments.delta":
|
||||||
|
return b.argsChunk(ev.Delta)
|
||||||
|
case "response.completed", "response.incomplete", "response.failed":
|
||||||
|
b.finishFrom(ev.Response)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// chunk 装配一条单 choice 增量事件。
|
||||||
|
func (b *ChatRespBridge) chunk(delta aiwire.Delta, finish *string) aiwire.ChatChunk {
|
||||||
|
return aiwire.ChatChunk{ID: b.id, Object: "chat.completion.chunk", Created: b.created,
|
||||||
|
Model: b.model, Choices: []aiwire.ChunkChoice{{Index: 0, Delta: delta, FinishReason: finish}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deltaWithRole 给首个增量补 role(OpenAI 首块携带 role 语义)。
|
||||||
|
func (b *ChatRespBridge) deltaWithRole(d aiwire.Delta) aiwire.Delta {
|
||||||
|
if !b.started {
|
||||||
|
b.started = true
|
||||||
|
d.Role = "assistant"
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// toolOpen 在新 function_call 输出项开始时发 id/name 增量并推进聚合 index;
|
||||||
|
// 非工具输出项只复位增量目标。
|
||||||
|
func (b *ChatRespBridge) toolOpen(item *respOutputItem) []aiwire.ChatChunk {
|
||||||
|
if item == nil || item.Type != "function_call" {
|
||||||
|
b.inTool = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b.toolIndex++
|
||||||
|
b.inTool = true
|
||||||
|
b.finish = "tool_calls"
|
||||||
|
d := aiwire.Delta{ToolCalls: []aiwire.ToolCallDelta{{Index: b.toolIndex, ID: item.CallID,
|
||||||
|
Type: "function", Function: aiwire.FunctionCallDelta{Name: item.Name}}}}
|
||||||
|
return []aiwire.ChatChunk{b.chunk(b.deltaWithRole(d), nil)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// argsChunk 输出当前工具的实参增量(仅在工具输出项进行中)。
|
||||||
|
func (b *ChatRespBridge) argsChunk(delta string) []aiwire.ChatChunk {
|
||||||
|
if !b.inTool {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
d := aiwire.Delta{ToolCalls: []aiwire.ToolCallDelta{{Index: b.toolIndex,
|
||||||
|
Function: aiwire.FunctionCallDelta{Arguments: delta}}}}
|
||||||
|
return []aiwire.ChatChunk{b.chunk(d, nil)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishFrom 记录终态 usage 与截断语义(max_output_tokens → length)。
|
||||||
|
func (b *ChatRespBridge) finishFrom(resp *respPayload) {
|
||||||
|
if resp == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.usage = usageFromResp(resp.Usage)
|
||||||
|
if respTruncated(resp) {
|
||||||
|
b.finish = "length"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish 在上游流结束后收尾:终块带 finish_reason;include_usage 时追加 usage 块
|
||||||
|
// (choices 为空数组,OpenAI 规范形态;上游未给 usage 时输出零值)。
|
||||||
|
func (b *ChatRespBridge) Finish() []aiwire.ChatChunk {
|
||||||
|
chunks := []aiwire.ChatChunk{b.chunk(b.deltaWithRole(aiwire.Delta{}), &b.finish)}
|
||||||
|
if !b.includeUsage {
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
usage := b.usage
|
||||||
|
if usage == nil {
|
||||||
|
usage = &aiwire.Usage{}
|
||||||
|
}
|
||||||
|
return append(chunks, aiwire.ChatChunk{ID: b.id, Object: "chat.completion.chunk",
|
||||||
|
Created: b.created, Model: b.model, Choices: []aiwire.ChunkChoice{}, Usage: usage})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage 返回聚合到的用量(供调用日志),上游未报告时为 nil。
|
||||||
|
func (b *ChatRespBridge) Usage() *aiwire.Usage { return b.usage }
|
||||||
|
|
||||||
|
// ChatResponseChunks 把完整 Chat 响应展开为 chunk 序列(内容与工具调用 →
|
||||||
|
// 终块 → 可选 usage 块),供流式上游断流后的非流式降级结果推送。
|
||||||
|
func ChatResponseChunks(resp *aiwire.ChatResponse, includeUsage bool) []aiwire.ChatChunk {
|
||||||
|
if len(resp.Choices) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
choice := resp.Choices[0]
|
||||||
|
mk := func(delta aiwire.Delta, finish *string) aiwire.ChatChunk {
|
||||||
|
return aiwire.ChatChunk{ID: resp.ID, Object: "chat.completion.chunk", Created: resp.Created,
|
||||||
|
Model: resp.Model, Choices: []aiwire.ChunkChoice{{Index: 0, Delta: delta, FinishReason: finish}}}
|
||||||
|
}
|
||||||
|
delta := aiwire.Delta{Role: "assistant", Content: choice.Message.Content.Text}
|
||||||
|
for i, tc := range choice.Message.ToolCalls {
|
||||||
|
delta.ToolCalls = append(delta.ToolCalls, aiwire.ToolCallDelta{Index: i, ID: tc.ID,
|
||||||
|
Type: "function", Function: aiwire.FunctionCallDelta{Name: tc.Function.Name, Arguments: tc.Function.Arguments}})
|
||||||
|
}
|
||||||
|
finish := choice.FinishReason
|
||||||
|
chunks := []aiwire.ChatChunk{mk(delta, nil), mk(aiwire.Delta{}, &finish)}
|
||||||
|
if includeUsage {
|
||||||
|
usage := resp.Usage
|
||||||
|
if usage == nil {
|
||||||
|
usage = &aiwire.Usage{}
|
||||||
|
}
|
||||||
|
chunks = append(chunks, aiwire.ChatChunk{ID: resp.ID, Object: "chat.completion.chunk",
|
||||||
|
Created: resp.Created, Model: resp.Model, Choices: []aiwire.ChunkChoice{}, Usage: usage})
|
||||||
|
}
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"oci-portal/internal/aiwire"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mustChatReq(t *testing.T, raw string) aiwire.ChatRequest {
|
||||||
|
t.Helper()
|
||||||
|
var req aiwire.ChatRequest
|
||||||
|
if err := json.Unmarshal([]byte(raw), &req); err != nil {
|
||||||
|
t.Fatalf("解析请求: %v", err)
|
||||||
|
}
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustChatBody(t *testing.T, raw string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
payload, err := ChatToResponsesBody(mustChatReq(t, raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ChatToResponsesBody: %v", err)
|
||||||
|
}
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(payload, &body); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChatToResponsesBody 断言消息展开、instructions 聚合、工具与顶层字段装配。
|
||||||
|
func TestChatToResponsesBody(t *testing.T) {
|
||||||
|
body := mustChatBody(t, `{
|
||||||
|
"model": "xai.grok-4.3", "max_tokens": 64, "max_completion_tokens": 128,
|
||||||
|
"temperature": 0.5, "top_p": 0.9, "parallel_tool_calls": false, "stream": true,
|
||||||
|
"reasoning_effort": "HIGH",
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "你是助手"},
|
||||||
|
{"role": "developer", "content": [{"type": "text", "text": "简洁作答"}]},
|
||||||
|
{"role": "user", "content": "东京天气?"},
|
||||||
|
{"role": "assistant", "content": "查询中", "tool_calls": [
|
||||||
|
{"id": "t1", "type": "function", "function": {"name": "get_weather", "arguments": "{\"city\":\"东京\"}"}}
|
||||||
|
]},
|
||||||
|
{"role": "tool", "tool_call_id": "t1", "content": "晴 25 度"},
|
||||||
|
{"role": "user", "content": "继续"}
|
||||||
|
],
|
||||||
|
"tools": [{"type": "function", "function": {"name": "get_weather", "description": "查天气", "parameters": {"type": "object"}}}],
|
||||||
|
"tool_choice": "required"
|
||||||
|
}`)
|
||||||
|
if body["model"] != "xai.grok-4.3" || body["store"] != false || body["stream"] != true {
|
||||||
|
t.Fatalf("顶层字段装配错误: %v", body)
|
||||||
|
}
|
||||||
|
if body["max_output_tokens"] != float64(128) {
|
||||||
|
t.Fatalf("max_output_tokens = %v, want 128(max_completion_tokens 优先)", body["max_output_tokens"])
|
||||||
|
}
|
||||||
|
if body["instructions"] != "你是助手\n\n简洁作答" {
|
||||||
|
t.Fatalf("instructions = %v", body["instructions"])
|
||||||
|
}
|
||||||
|
if body["temperature"] != 0.5 || body["top_p"] != 0.9 || body["parallel_tool_calls"] != false {
|
||||||
|
t.Fatalf("采样字段装配错误: %v", body)
|
||||||
|
}
|
||||||
|
if body["tool_choice"] != "required" {
|
||||||
|
t.Fatalf("tool_choice = %v", body["tool_choice"])
|
||||||
|
}
|
||||||
|
if reasoning, _ := body["reasoning"].(map[string]any); reasoning["effort"] != "high" {
|
||||||
|
t.Fatalf("effort = %v, want high(小写透传)", body["reasoning"])
|
||||||
|
}
|
||||||
|
kinds := chatInputKinds(t, body)
|
||||||
|
want := "message/user,message/assistant,function_call,function_call_output,message/user"
|
||||||
|
if kinds != want {
|
||||||
|
t.Fatalf("input 顺序 = %s, want %s", kinds, want)
|
||||||
|
}
|
||||||
|
tools, _ := body["tools"].([]any)
|
||||||
|
tool, _ := tools[0].(map[string]any)
|
||||||
|
if tool["type"] != "function" || tool["name"] != "get_weather" {
|
||||||
|
t.Fatalf("工具应拍平为 Responses 形态: %v", tool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func chatInputKinds(t *testing.T, body map[string]any) string {
|
||||||
|
t.Helper()
|
||||||
|
input, _ := body["input"].([]any)
|
||||||
|
kinds := make([]string, 0, len(input))
|
||||||
|
for _, it := range input {
|
||||||
|
m := it.(map[string]any)
|
||||||
|
if ty, ok := m["type"].(string); ok {
|
||||||
|
kinds = append(kinds, ty)
|
||||||
|
} else {
|
||||||
|
kinds = append(kinds, "message/"+m["role"].(string))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(kinds, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChatToResponsesBodyContent 断言文本部件按角色定型与图片装配/拒绝。
|
||||||
|
func TestChatToResponsesBodyContent(t *testing.T) {
|
||||||
|
body := mustChatBody(t, `{"model":"m","messages":[
|
||||||
|
{"role":"user","content":[
|
||||||
|
{"type":"text","text":"看图"},
|
||||||
|
{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJD"}}]},
|
||||||
|
{"role":"assistant","content":"这是猫"}]}`)
|
||||||
|
raw, _ := json.Marshal(body["input"])
|
||||||
|
if !strings.Contains(string(raw), `"input_image"`) ||
|
||||||
|
!strings.Contains(string(raw), "data:image/png;base64,QUJD") {
|
||||||
|
t.Fatalf("图片应转 input_image: %s", raw)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(raw), `"output_text"`) {
|
||||||
|
t.Fatalf("assistant 历史文本应为 output_text 部件: %s", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, msg := range map[string]string{
|
||||||
|
"audio 块": `{"role":"user","content":[{"type":"input_audio","input_audio":{}}]}`,
|
||||||
|
"图片缺 url": `{"role":"user","content":[{"type":"image_url","image_url":{}}]}`,
|
||||||
|
} {
|
||||||
|
raw := `{"model":"m","messages":[` + msg + `]}`
|
||||||
|
if _, err := ChatToResponsesBody(mustChatReq(t, raw)); err == nil {
|
||||||
|
t.Errorf("%s 应拒绝", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
badTool := mustChatReq(t, `{"model":"m","messages":[{"role":"user","content":"hi"}],
|
||||||
|
"tools":[{"type":"web_search","function":{}}]}`)
|
||||||
|
if _, err := ChatToResponsesBody(badTool); err == nil {
|
||||||
|
t.Error("非 function 工具类型应拒绝")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChatRespToolChoice 断言 tool_choice 各形态映射。
|
||||||
|
func TestChatRespToolChoice(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name, raw string
|
||||||
|
want any
|
||||||
|
}{
|
||||||
|
{"auto", `"auto"`, "auto"},
|
||||||
|
{"none", `"none"`, "none"},
|
||||||
|
{"required", `"required"`, "required"},
|
||||||
|
{"具名 function", `{"type":"function","function":{"name":"f1"}}`,
|
||||||
|
map[string]string{"type": "function", "name": "f1"}},
|
||||||
|
{"未知 string", `"whatever"`, nil},
|
||||||
|
{"缺 name", `{"type":"function","function":{}}`, nil},
|
||||||
|
{"空", ``, nil},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := chatRespToolChoice(json.RawMessage(tc.raw))
|
||||||
|
if gotMap, ok := got.(map[string]string); ok {
|
||||||
|
wantMap, _ := tc.want.(map[string]string)
|
||||||
|
if wantMap == nil || gotMap["name"] != wantMap["name"] {
|
||||||
|
t.Errorf("%s: got %v, want %v", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("%s: got %v, want %v", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChatTextFormat 断言 response_format 拍平映射。
|
||||||
|
func TestChatTextFormat(t *testing.T) {
|
||||||
|
if got := chatTextFormat(nil); got != nil {
|
||||||
|
t.Errorf("nil 应不下发: %v", got)
|
||||||
|
}
|
||||||
|
if got := chatTextFormat(&aiwire.ResponseFormat{Type: "text"}); got != nil {
|
||||||
|
t.Errorf("text 应不下发: %v", got)
|
||||||
|
}
|
||||||
|
if got := chatTextFormat(&aiwire.ResponseFormat{Type: "json_object"}); got["type"] != "json_object" {
|
||||||
|
t.Errorf("json_object: %v", got)
|
||||||
|
}
|
||||||
|
got := chatTextFormat(&aiwire.ResponseFormat{Type: "json_schema",
|
||||||
|
JSONSchema: json.RawMessage(`{"name":"out","schema":{"type":"object"},"strict":true}`)})
|
||||||
|
if got["type"] != "json_schema" || got["name"] != "out" || got["strict"] != true {
|
||||||
|
t.Errorf("json_schema 应提升嵌套字段: %v", got)
|
||||||
|
}
|
||||||
|
if _, ok := got["schema"]; !ok {
|
||||||
|
t.Errorf("schema 缺失: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResponsesToChat 断言文本聚合、tool_calls、finish_reason 与 usage 回转。
|
||||||
|
func TestResponsesToChat(t *testing.T) {
|
||||||
|
payload := []byte(`{"model":"xai.grok-4.3","status":"completed","output":[
|
||||||
|
{"type":"reasoning","summary":[]},
|
||||||
|
{"type":"message","content":[{"type":"output_text","text":"你"},{"type":"output_text","text":"好"}]},
|
||||||
|
{"type":"function_call","call_id":"c1","name":"get_weather","arguments":"{\"city\":\"东京\"}"}],
|
||||||
|
"usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15,"input_tokens_details":{"cached_tokens":4}}}`)
|
||||||
|
out, err := ResponsesToChat(payload, "chatcmpl-1", 1700000000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResponsesToChat: %v", err)
|
||||||
|
}
|
||||||
|
if out.Object != "chat.completion" || out.ID != "chatcmpl-1" || out.Created != 1700000000 {
|
||||||
|
t.Fatalf("响应骨架错误: %+v", out)
|
||||||
|
}
|
||||||
|
msg := out.Choices[0].Message
|
||||||
|
if msg.Role != "assistant" || msg.Content.JoinText() != "你好" {
|
||||||
|
t.Fatalf("content 装配错误: %+v", msg)
|
||||||
|
}
|
||||||
|
if len(msg.ToolCalls) != 1 || msg.ToolCalls[0].ID != "c1" ||
|
||||||
|
msg.ToolCalls[0].Function.Arguments != `{"city":"东京"}` {
|
||||||
|
t.Fatalf("tool_calls 装配错误: %+v", msg.ToolCalls)
|
||||||
|
}
|
||||||
|
if out.Choices[0].FinishReason != "tool_calls" {
|
||||||
|
t.Fatalf("finish_reason = %s, want tool_calls", out.Choices[0].FinishReason)
|
||||||
|
}
|
||||||
|
if out.Usage.PromptTokens != 10 || out.Usage.CompletionTokens != 5 || out.Usage.CachedTokens() != 4 {
|
||||||
|
t.Fatalf("usage = %+v", out.Usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
trunc := []byte(`{"model":"m","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},
|
||||||
|
"output":[{"type":"message","content":[{"type":"output_text","text":"半"}]}]}`)
|
||||||
|
out2, err := ResponsesToChat(trunc, "chatcmpl-2", 1)
|
||||||
|
if err != nil || out2.Choices[0].FinishReason != "length" {
|
||||||
|
t.Fatalf("截断 finish_reason = %+v, %v", out2.Choices, err)
|
||||||
|
}
|
||||||
|
if out2.Usage != nil {
|
||||||
|
t.Fatalf("无 usage 时应为 nil: %+v", out2.Usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func chatChunkShapes(chunks []aiwire.ChatChunk) string {
|
||||||
|
kinds := make([]string, 0, len(chunks))
|
||||||
|
for _, ch := range chunks {
|
||||||
|
switch {
|
||||||
|
case len(ch.Choices) == 0:
|
||||||
|
kinds = append(kinds, "usage")
|
||||||
|
case ch.Choices[0].FinishReason != nil:
|
||||||
|
kinds = append(kinds, "finish:"+*ch.Choices[0].FinishReason)
|
||||||
|
case len(ch.Choices[0].Delta.ToolCalls) > 0:
|
||||||
|
kinds = append(kinds, "tool")
|
||||||
|
case ch.Choices[0].Delta.Role != "":
|
||||||
|
kinds = append(kinds, "role+text")
|
||||||
|
default:
|
||||||
|
kinds = append(kinds, "text")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(kinds, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChatRespBridge 断言流桥:首块 role、文本与工具增量、reasoning 丢弃、usage 块。
|
||||||
|
func TestChatRespBridge(t *testing.T) {
|
||||||
|
b := NewChatRespBridge("chatcmpl-1", "m1", 1700000000, true)
|
||||||
|
var chunks []aiwire.ChatChunk
|
||||||
|
feed := func(lines ...string) {
|
||||||
|
for _, l := range lines {
|
||||||
|
chunks = append(chunks, b.Feed([]byte(l))...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
feed(`{"type":"response.created","response":{"model":"m1"}}`,
|
||||||
|
`{"type":"response.reasoning_text.delta","delta":"思考中"}`,
|
||||||
|
`{"type":"response.output_text.delta","delta":"你"}`,
|
||||||
|
`{"type":"response.output_text.delta","delta":"好"}`,
|
||||||
|
`{"type":"response.output_item.added","item":{"type":"function_call","call_id":"c1","name":"f"}}`,
|
||||||
|
`{"type":"response.function_call_arguments.delta","delta":"{\"a\":"}`,
|
||||||
|
`{"type":"response.function_call_arguments.delta","delta":"1}"}`,
|
||||||
|
`{"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":8,"output_tokens":4,"total_tokens":12}}}`)
|
||||||
|
chunks = append(chunks, b.Finish()...)
|
||||||
|
|
||||||
|
got := chatChunkShapes(chunks)
|
||||||
|
want := "role+text,text,tool,tool,tool,finish:tool_calls,usage"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("chunk 序列:\n got %s\nwant %s", got, want)
|
||||||
|
}
|
||||||
|
if chunks[0].Choices[0].Delta.Role != "assistant" || chunks[0].Choices[0].Delta.Content != "你" {
|
||||||
|
t.Fatalf("首块应含 role 与文本: %+v", chunks[0].Choices[0].Delta)
|
||||||
|
}
|
||||||
|
tool := chunks[2].Choices[0].Delta.ToolCalls[0]
|
||||||
|
if tool.Index != 0 || tool.ID != "c1" || tool.Function.Name != "f" {
|
||||||
|
t.Fatalf("工具首块 = %+v", tool)
|
||||||
|
}
|
||||||
|
args := chunks[3].Choices[0].Delta.ToolCalls[0].Function.Arguments +
|
||||||
|
chunks[4].Choices[0].Delta.ToolCalls[0].Function.Arguments
|
||||||
|
if args != `{"a":1}` {
|
||||||
|
t.Fatalf("实参增量聚合 = %s", args)
|
||||||
|
}
|
||||||
|
last := chunks[len(chunks)-1]
|
||||||
|
if last.Usage == nil || last.Usage.PromptTokens != 8 || len(last.Choices) != 0 {
|
||||||
|
t.Fatalf("usage 块 = %+v", last)
|
||||||
|
}
|
||||||
|
if b.Usage().TotalTokens != 12 {
|
||||||
|
t.Fatalf("Usage() = %+v", b.Usage())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChatRespBridgeVariants 断言 include_usage 关闭、截断与空流的收尾形态。
|
||||||
|
func TestChatRespBridgeVariants(t *testing.T) {
|
||||||
|
noUsage := NewChatRespBridge("c", "m", 1, false)
|
||||||
|
noUsage.Feed([]byte(`{"type":"response.output_text.delta","delta":"x"}`))
|
||||||
|
noUsage.Feed([]byte(`{"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}}}`))
|
||||||
|
if got := chatChunkShapes(noUsage.Finish()); got != "finish:length" {
|
||||||
|
t.Errorf("截断且不带 usage 块: %s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
empty := NewChatRespBridge("c", "m", 1, false)
|
||||||
|
fin := empty.Finish()
|
||||||
|
if got := chatChunkShapes(fin); got != "finish:stop" {
|
||||||
|
t.Errorf("空流收尾 = %s", got)
|
||||||
|
}
|
||||||
|
if fin[0].Choices[0].Delta.Role != "assistant" {
|
||||||
|
t.Errorf("空流终块应补 role: %+v", fin[0].Choices[0].Delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
+121
-24
@@ -57,9 +57,6 @@ type LogEventService struct {
|
|||||||
|
|
||||||
relayPollTick time.Duration // 订阅确认轮询间隔,零值用默认
|
relayPollTick time.Duration // 订阅确认轮询间隔,零值用默认
|
||||||
relayPollTimeout time.Duration // 订阅确认轮询上限,零值用默认
|
relayPollTimeout time.Duration // 订阅确认轮询上限,零值用默认
|
||||||
|
|
||||||
alertMu sync.Mutex // 保护告警规则冷却表
|
|
||||||
alertSentAt map[uint]time.Time // 规则 ID → 上次告警时刻(阈值型规则冷却)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLogEventService 组装依赖;调用 StartParser / StartCleanup 后台协程后生效。
|
// NewLogEventService 组装依赖;调用 StartParser / StartCleanup 后台协程后生效。
|
||||||
@@ -373,20 +370,18 @@ func (s *LogEventService) parseOnce(ctx context.Context) {
|
|||||||
if len(events) == 0 {
|
if len(events) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rules := s.loadEnabledAlertRules(ctx)
|
|
||||||
for i := range events {
|
for i := range events {
|
||||||
s.processLogEvent(ctx, &events[i], rules)
|
s.processLogEvent(ctx, &events[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// processLogEvent 仅在条件更新命中原行后触发通知,删除并发胜出时静默跳过。
|
// processLogEvent 仅在条件更新命中原行后触发通知,删除并发胜出时静默跳过。
|
||||||
func (s *LogEventService) processLogEvent(ctx context.Context, event *model.LogEvent, rules []model.AlertRule) {
|
func (s *LogEventService) processLogEvent(ctx context.Context, event *model.LogEvent) {
|
||||||
parsed := parseLogEvent([]byte(event.Payload))
|
parsed := parseLogEvent([]byte(event.Payload))
|
||||||
if !s.updateParsedEvent(ctx, event, parsed) {
|
if !s.updateParsedEvent(ctx, event, parsed) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.notifyCritical(ctx, event, parsed)
|
s.notifyCritical(ctx, event, parsed)
|
||||||
s.matchAlertRules(ctx, rules, event, parsed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateParsedEvent 用条件 UPDATE 禁止 Save 在删除后隐式重建事件。
|
// updateParsedEvent 用条件 UPDATE 禁止 Save 在删除后隐式重建事件。
|
||||||
@@ -413,26 +408,49 @@ func (s *LogEventService) updateParsedEvent(ctx context.Context, event *model.Lo
|
|||||||
// onsEnvelope 覆盖 ONS 消息与 CloudEvents 审计事件的常见字段;
|
// onsEnvelope 覆盖 ONS 消息与 CloudEvents 审计事件的常见字段;
|
||||||
// 真实格式以联调实测为准,提不出字段时只置 Processed 不回填。
|
// 真实格式以联调实测为准,提不出字段时只置 Processed 不回填。
|
||||||
type onsEnvelope struct {
|
type onsEnvelope struct {
|
||||||
EventType string `json:"eventType"`
|
EventType string `json:"eventType"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
EventTime string `json:"eventTime"`
|
EventTime string `json:"eventTime"`
|
||||||
ResourceName string `json:"resourceName"`
|
ResourceName string `json:"resourceName"`
|
||||||
Data json.RawMessage `json:"data"`
|
Message string `json:"message"`
|
||||||
Identity *onsIdentity `json:"identity"`
|
Data json.RawMessage `json:"data"`
|
||||||
|
Identity *onsIdentity `json:"identity"`
|
||||||
|
AdditionalDetails *onsAddDetails `json:"additionalDetails"`
|
||||||
|
StateChange *onsStateChange `json:"stateChange"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// onsIdentity 是 Audit 事件 data.identity 中与展示相关的字段。
|
// onsIdentity 是 Audit 事件 data.identity 中与展示相关的字段。
|
||||||
type onsIdentity struct {
|
type onsIdentity struct {
|
||||||
IPAddress string `json:"ipAddress"`
|
IPAddress string `json:"ipAddress"`
|
||||||
|
PrincipalName string `json:"principalName"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// parsedEvent 是从消息原文提取的展示字段集;ResourceName 仅供 P2 推送文案,不落库。
|
// onsAddDetails 是 IDCS 登录类事件 data.additionalDetails 的补充字段;
|
||||||
|
// AuditEventMapValue 为嵌套的 JSON 字符串(含 eventId 成败与失败原因)。
|
||||||
|
type onsAddDetails struct {
|
||||||
|
ActorName string `json:"actorName"`
|
||||||
|
ClientIP string `json:"clientIp"`
|
||||||
|
AuditEventMapValue string `json:"auditEventMapValue"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// onsStateChange 承载 Audit v2 的资源变更快照;description 供策略类事件推送文案。
|
||||||
|
type onsStateChange struct {
|
||||||
|
Current struct {
|
||||||
|
Description string `json:"description"`
|
||||||
|
} `json:"current"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsedEvent 是从消息原文提取的展示字段集;ResourceName/Actor/Outcome/Detail
|
||||||
|
// 仅供 P2 推送文案,不落库。
|
||||||
type parsedEvent struct {
|
type parsedEvent struct {
|
||||||
EventType string
|
EventType string
|
||||||
Source string
|
Source string
|
||||||
SourceIP string
|
SourceIP string
|
||||||
ResourceName string
|
ResourceName string
|
||||||
|
Actor string // 操作者(identity.principalName,登录事件回退 actorName)
|
||||||
|
Outcome string // 成功 / 失败 / 空(判读不出)
|
||||||
|
Detail string // 补充说明(策略描述、登录失败原因等)
|
||||||
EventTime *time.Time
|
EventTime *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,7 +473,7 @@ func parseLogEvent(payload []byte) parsedEvent {
|
|||||||
return envelopeFields(env)
|
return envelopeFields(env)
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeEnvelope 外层缺失字段时以 data 内层补齐(Audit 的 identity 在内层)。
|
// mergeEnvelope 外层缺失字段时以 data 内层补齐(Audit 的 identity 等在内层)。
|
||||||
func mergeEnvelope(outer, inner onsEnvelope) onsEnvelope {
|
func mergeEnvelope(outer, inner onsEnvelope) onsEnvelope {
|
||||||
if outer.EventType == "" {
|
if outer.EventType == "" {
|
||||||
outer.EventType = inner.EventType
|
outer.EventType = inner.EventType
|
||||||
@@ -472,13 +490,22 @@ func mergeEnvelope(outer, inner onsEnvelope) onsEnvelope {
|
|||||||
if outer.ResourceName == "" {
|
if outer.ResourceName == "" {
|
||||||
outer.ResourceName = inner.ResourceName
|
outer.ResourceName = inner.ResourceName
|
||||||
}
|
}
|
||||||
|
if outer.Message == "" {
|
||||||
|
outer.Message = inner.Message
|
||||||
|
}
|
||||||
if outer.Identity == nil {
|
if outer.Identity == nil {
|
||||||
outer.Identity = inner.Identity
|
outer.Identity = inner.Identity
|
||||||
}
|
}
|
||||||
|
if outer.AdditionalDetails == nil {
|
||||||
|
outer.AdditionalDetails = inner.AdditionalDetails
|
||||||
|
}
|
||||||
|
if outer.StateChange == nil {
|
||||||
|
outer.StateChange = inner.StateChange
|
||||||
|
}
|
||||||
return outer
|
return outer
|
||||||
}
|
}
|
||||||
|
|
||||||
// envelopeFields 收敛字段别名并解析事件时间。
|
// envelopeFields 收敛字段别名并解析事件时间与推送用补充字段。
|
||||||
func envelopeFields(env onsEnvelope) parsedEvent {
|
func envelopeFields(env onsEnvelope) parsedEvent {
|
||||||
eventType := env.EventType
|
eventType := env.EventType
|
||||||
if eventType == "" {
|
if eventType == "" {
|
||||||
@@ -490,19 +517,81 @@ func envelopeFields(env onsEnvelope) parsedEvent {
|
|||||||
eventTime = &ts
|
eventTime = &ts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ip := ""
|
outcome, detail := envOutcome(env)
|
||||||
if env.Identity != nil {
|
|
||||||
ip = env.Identity.IPAddress
|
|
||||||
}
|
|
||||||
return parsedEvent{
|
return parsedEvent{
|
||||||
EventType: clip(eventType, 128),
|
EventType: clip(eventType, 128),
|
||||||
Source: clip(env.Source, 64),
|
Source: clip(env.Source, 64),
|
||||||
SourceIP: clip(ip, 64),
|
SourceIP: clip(envIP(env), 64),
|
||||||
ResourceName: clip(env.ResourceName, 128),
|
ResourceName: clip(env.ResourceName, 128),
|
||||||
|
Actor: clipRunes(envActor(env), 64),
|
||||||
|
Outcome: outcome,
|
||||||
|
Detail: clipRunes(detail, 200),
|
||||||
EventTime: eventTime,
|
EventTime: eventTime,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// envIP 取发起方 IP:Audit 的 identity.ipAddress,登录事件回退 additionalDetails.clientIp。
|
||||||
|
func envIP(env onsEnvelope) string {
|
||||||
|
if env.Identity != nil && env.Identity.IPAddress != "" {
|
||||||
|
return env.Identity.IPAddress
|
||||||
|
}
|
||||||
|
if env.AdditionalDetails != nil {
|
||||||
|
return env.AdditionalDetails.ClientIP
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// envActor 取操作者:Audit 的 identity.principalName,登录事件回退 additionalDetails.actorName。
|
||||||
|
func envActor(env onsEnvelope) string {
|
||||||
|
if env.Identity != nil && env.Identity.PrincipalName != "" {
|
||||||
|
return env.Identity.PrincipalName
|
||||||
|
}
|
||||||
|
if env.AdditionalDetails != nil {
|
||||||
|
return env.AdditionalDetails.ActorName
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// envOutcome 判读事件成败与补充说明:登录事件解析 auditEventMapValue;其余
|
||||||
|
// Audit 事件看 message 后缀,补充说明取 stateChange.current.description(策略描述)。
|
||||||
|
func envOutcome(env onsEnvelope) (outcome, detail string) {
|
||||||
|
if env.StateChange != nil {
|
||||||
|
detail = env.StateChange.Current.Description
|
||||||
|
}
|
||||||
|
if env.AdditionalDetails != nil && env.AdditionalDetails.AuditEventMapValue != "" {
|
||||||
|
return ssoOutcome(env.AdditionalDetails.AuditEventMapValue, detail)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(env.Message, " succeeded"):
|
||||||
|
outcome = "成功"
|
||||||
|
case strings.HasSuffix(env.Message, " failed"):
|
||||||
|
outcome = "失败"
|
||||||
|
}
|
||||||
|
return outcome, detail
|
||||||
|
}
|
||||||
|
|
||||||
|
// ssoOutcome 解析 IDCS 审计负载(JSON 字符串):eventId 含 success/failure 定成败,
|
||||||
|
// 失败时以其 message 作为原因说明。
|
||||||
|
func ssoOutcome(raw, detail string) (string, string) {
|
||||||
|
var ev struct {
|
||||||
|
EventID string `json:"eventId"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal([]byte(raw), &ev) != nil {
|
||||||
|
return "", detail
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.Contains(ev.EventID, "success"):
|
||||||
|
return "成功", detail
|
||||||
|
case strings.Contains(ev.EventID, "failure"):
|
||||||
|
if ev.Message != "" {
|
||||||
|
detail = ev.Message
|
||||||
|
}
|
||||||
|
return "失败", detail
|
||||||
|
}
|
||||||
|
return "", detail
|
||||||
|
}
|
||||||
|
|
||||||
// clip 按模型列宽截断解析出的字段。
|
// clip 按模型列宽截断解析出的字段。
|
||||||
func clip(s string, max int) string {
|
func clip(s string, max int) string {
|
||||||
if len(s) > max {
|
if len(s) > max {
|
||||||
@@ -511,6 +600,15 @@ func clip(s string, max int) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clipRunes 按字符数截断(通知文案用,中文安全)。
|
||||||
|
func clipRunes(s string, max int) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) > max {
|
||||||
|
return string(r[:max])
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// StartCleanup 启动周期清理:启动即清一次,之后每 24h 一次,随 ctx 取消退出。
|
// StartCleanup 启动周期清理:启动即清一次,之后每 24h 一次,随 ctx 取消退出。
|
||||||
func (s *LogEventService) StartCleanup(ctx context.Context) {
|
func (s *LogEventService) StartCleanup(ctx context.Context) {
|
||||||
s.wg.Add(1)
|
s.wg.Add(1)
|
||||||
@@ -535,7 +633,6 @@ func (s *LogEventService) cleanupOnce(ctx context.Context) {
|
|||||||
if err := s.cleanup(ctx, logEventRetention, logEventMaxRows); err != nil {
|
if err := s.cleanup(ctx, logEventRetention, logEventMaxRows); err != nil {
|
||||||
log.Printf("log event cleanup: %v", err)
|
log.Printf("log event cleanup: %v", err)
|
||||||
}
|
}
|
||||||
s.cleanupAlertHits(ctx)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanup 先删过期记录,再对超量部分删最旧;阈值参数化便于测试。
|
// cleanup 先删过期记录,再对超量部分删最旧;阈值参数化便于测试。
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ func newLogEventEnv(t *testing.T) (*LogEventService, *gorm.DB, uint) {
|
|||||||
t.Fatalf("db handle: %v", err)
|
t.Fatalf("db handle: %v", err)
|
||||||
}
|
}
|
||||||
sqlDB.SetMaxOpenConns(1)
|
sqlDB.SetMaxOpenConns(1)
|
||||||
if err := db.AutoMigrate(&model.Setting{}, &model.OciConfig{}, &model.LogEvent{},
|
if err := db.AutoMigrate(&model.Setting{}, &model.OciConfig{}, &model.LogEvent{}); err != nil {
|
||||||
&model.AlertRule{}, &model.AlertRuleHit{}); err != nil {
|
|
||||||
t.Fatalf("auto migrate: %v", err)
|
t.Fatalf("auto migrate: %v", err)
|
||||||
}
|
}
|
||||||
cfg := model.OciConfig{Alias: "测试租户"}
|
cfg := model.OciConfig{Alias: "测试租户"}
|
||||||
@@ -240,12 +239,15 @@ func TestIngestRejectsUnknownConfig(t *testing.T) {
|
|||||||
func TestParseLogEvent(t *testing.T) {
|
func TestParseLogEvent(t *testing.T) {
|
||||||
ts := "2026-07-07T08:00:00Z"
|
ts := "2026-07-07T08:00:00Z"
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
payload string
|
payload string
|
||||||
wantType string
|
wantType string
|
||||||
wantSource string
|
wantSource string
|
||||||
wantIP string
|
wantIP string
|
||||||
wantTime bool
|
wantTime bool
|
||||||
|
wantActor string
|
||||||
|
wantOutcome string
|
||||||
|
wantDetail string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "CloudEvents 单事件",
|
name: "CloudEvents 单事件",
|
||||||
@@ -283,6 +285,29 @@ func TestParseLogEvent(t *testing.T) {
|
|||||||
payload: `{"eventType":"x","data":{"identity":{}}}`,
|
payload: `{"eventType":"x","data":{"identity":{}}}`,
|
||||||
wantType: "x",
|
wantType: "x",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Audit v2 提取操作者成败与策略描述",
|
||||||
|
payload: `{"eventType":"com.oraclecloud.identityControlPlane.CreatePolicy","data":{
|
||||||
|
"identity":{"principalName":"Alfonso Garcia","ipAddress":"129.159.43.9"},
|
||||||
|
"message":"ociportal-logs-sch CreatePolicy succeeded",
|
||||||
|
"stateChange":{"current":{"description":"允许 Connector 发布到 ONS"}}}}`,
|
||||||
|
wantType: "com.oraclecloud.identityControlPlane.CreatePolicy", wantIP: "129.159.43.9",
|
||||||
|
wantActor: "Alfonso Garcia", wantOutcome: "成功", wantDetail: "允许 Connector 发布到 ONS",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IDCS 登录失败提取用户 IP 与原因",
|
||||||
|
payload: `{"eventType":"com.oraclecloud.IdentitySignOn.InteractiveLogin","data":{
|
||||||
|
"additionalDetails":{"actorName":"oci","clientIp":"155.117.82.111",
|
||||||
|
"auditEventMapValue":"{\"eventId\":\"sso.authentication.failure\",\"message\":\"Authentication failure : incorrect password.\"}"}}}`,
|
||||||
|
wantType: "com.oraclecloud.IdentitySignOn.InteractiveLogin", wantIP: "155.117.82.111",
|
||||||
|
wantActor: "oci", wantOutcome: "失败", wantDetail: "Authentication failure : incorrect password.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "message failed 后缀判失败",
|
||||||
|
payload: `{"eventType":"x","data":{"message":"vm TerminateInstance failed"}}`,
|
||||||
|
wantType: "x",
|
||||||
|
wantOutcome: "失败",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -291,6 +316,10 @@ func TestParseLogEvent(t *testing.T) {
|
|||||||
t.Errorf("parse = (%q,%q,%q), want (%q,%q,%q)",
|
t.Errorf("parse = (%q,%q,%q), want (%q,%q,%q)",
|
||||||
got.EventType, got.Source, got.SourceIP, tt.wantType, tt.wantSource, tt.wantIP)
|
got.EventType, got.Source, got.SourceIP, tt.wantType, tt.wantSource, tt.wantIP)
|
||||||
}
|
}
|
||||||
|
if got.Actor != tt.wantActor || got.Outcome != tt.wantOutcome || got.Detail != tt.wantDetail {
|
||||||
|
t.Errorf("推送字段 = (%q,%q,%q), want (%q,%q,%q)",
|
||||||
|
got.Actor, got.Outcome, got.Detail, tt.wantActor, tt.wantOutcome, tt.wantDetail)
|
||||||
|
}
|
||||||
if (got.EventTime != nil) != tt.wantTime {
|
if (got.EventTime != nil) != tt.wantTime {
|
||||||
t.Errorf("eventTime present = %v, want %v", got.EventTime != nil, tt.wantTime)
|
t.Errorf("eventTime present = %v, want %v", got.EventTime != nil, tt.wantTime)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -322,12 +322,34 @@ func relayEventShortName(eventType string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// criticalEventVars 判定关键事件并生成模板变量;非关键事件 ok 为 false。
|
// criticalEventVars 判定关键事件并生成模板变量;非关键事件 ok 为 false。
|
||||||
|
// actor/ip/outcome/resource 缺失时兜底 —,detail 包装为独立行(空则不占行)。
|
||||||
func criticalEventVars(alias string, p parsedEvent) (map[string]string, bool) {
|
func criticalEventVars(alias string, p parsedEvent) (map[string]string, bool) {
|
||||||
name := relayEventShortName(p.EventType)
|
name := relayEventShortName(p.EventType)
|
||||||
if name == "" || !relayCriticalSet[name] {
|
if name == "" || !relayCriticalSet[name] {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
return map[string]string{"tenant": alias, "event": name, "resource": p.ResourceName}, true
|
return map[string]string{
|
||||||
|
"tenant": alias, "event": name,
|
||||||
|
"resource": orDash(p.ResourceName), "actor": orDash(p.Actor),
|
||||||
|
"ip": orDash(p.SourceIP), "outcome": orDash(p.Outcome),
|
||||||
|
"detail": detailLine(p.Detail),
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// orDash 空值兜底为 —,避免模板出现悬空标签。
|
||||||
|
func orDash(v string) string {
|
||||||
|
if v == "" {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// detailLine 把补充说明包装为独立行;为空时不产生多余空行。
|
||||||
|
func detailLine(v string) string {
|
||||||
|
if v == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "\n" + v
|
||||||
}
|
}
|
||||||
|
|
||||||
// notifyCritical 对关键事件推送告警;依赖未注入或对应子类开关关闭时跳过。
|
// notifyCritical 对关键事件推送告警;依赖未注入或对应子类开关关闭时跳过。
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user