16 Commits
Author SHA1 Message Date
wangdefa da7b29d2e3 设置页AI Tab:网关设置入库、保险丝改维度与grok工具注入
CI / test (push) Successful in 33s
2026-07-16 12:31:36 +08:00
wangdefa 99b551401e 实例地址恒以主网卡为准,修复多网卡覆写随机 2026-07-16 12:31:36 +08:00
wangdefa 4e2bab3032 升级 oci-go-sdk 到 v65.121.0
CI / test (push) Successful in 44s
2026-07-15 14:23:01 +08:00
wangdefa e1f8a0539c AI网关:Responses直通codex兼容与流式升级回退
CI / test (push) Successful in 31s
Release / release (push) Successful in 39s
2026-07-15 10:39:56 +08:00
wangdefa a8bde89b56 更新 DASH_VERSION 至 v0.6.0
CI / test (push) Successful in 46s
Release / release (push) Successful in 43s
2026-07-14 19:33:33 +08:00
wangdefa 1da2197a6c 渠道模型列表与测试端点,修复探测无配额误判 2026-07-14 19:33:33 +08:00
wangdefa 79c9e4d9b9 更新 DASH_VERSION 至 v0.5.1
CI / test (push) Successful in 24s
Release / release (push) Successful in 55s
2026-07-14 11:04:56 +08:00
wangdefa 2fea315430 OIDC 登录/绑定回调写系统日志
CI / test (push) Successful in 30s
2026-07-14 11:00:55 +08:00
wangdefa b4ef98a25e AI网关新增xAI格式TTS端点与过滤弃用模型开关
CI / test (push) Successful in 32s
Release / release (push) Successful in 54s
- POST /ai/v1/tts:xAI 官方 TTS 格式转换层(text/voice_id→input/voice),复用 Speech 编排,model 缺省 xai.grok-tts;实测 output_format/speed 透传生效
- 「过滤弃用模型」开关(GET/PUT /api/v1/ai-settings,settings 表持久化):开启后已宣布弃用(未退役)模型从列表与路由排除;同步入库与退役提醒不受影响
- AI 网关文档更名 docs/AI网关.md,补 tts 矩阵段与开关说明;README 引用同步
- CHANGELOG 0.5.0(0.4.0 已发版冻结);DASH_VERSION v0.5.0
2026-07-14 09:49:28 +08:00
wangdefa 0a86b5a291 AI网关新增TTS/重排/审核端点,xAI工具扩展,swagger修缺
CI / test (push) Successful in 32s
Release / release (push) Successful in 1m4s
- 新端点 /ai/v1/audio/speech(xai.grok-tts)、/rerank(cohere.rerank-v4)、/moderations(OCI Guardrails)
- Responses 放行 code_interpreter 与远程 mcp 工具,web_search/x_search 解除仅非流式限制
- 模型能力映射扩展:TEXT_RERANK→RERANK、TEXT_TO_AUDIO→TTS
- AI 网关文档独立 docs/ai-gateway.md,字段兼容矩阵只列支持项;README 精简引用
- swagger 修缺:135 处响应注解具体化,RawMessage/联合类型统一渲染 AnyJSON,overrides 迁至 docs/.swaggo
- CHANGELOG 0.4.0,版本段不再记日期;DASH_VERSION v0.4.0
2026-07-13 20:17:06 +08:00
wangdefa 9309ad1ffc 修复 AI 网关流式断流:错误透传、自动降级,max_tokens 可缺省
CI / test (push) Successful in 33s
Release / release (push) Successful in 56s
2026-07-13 15:06:01 +08:00
wangdefa 81d4650f3d 代理批量关联租户接口、关于页运行时与资源指标
CI / test (push) Successful in 31s
Release / release (push) Successful in 57s
2026-07-13 12:23:18 +08:00
wangdefa c7cc5616ed 发布 0.3.0:恢复 Chat 接口并增强云端事件通知
CI / test (push) Successful in 31s
Release / release (push) Successful in 48s
2026-07-13 10:13:44 +08:00
wangdefa 489cb49cb3 AI 网关切换 OpenAI 兼容面并移除 chat 端点,新增模型黑白名单
CI / test (push) Successful in 31s
Release / release (push) Successful in 54s
2026-07-12 17:48:28 +08:00
wangdefa 7706f59549 发布 0.2.0:模型池自愈、探测修正、任务异步触发与删除加固
CI / test (push) Successful in 32s
Release / release (push) Successful in 52s
2026-07-10 20:25:37 +08:00
Wang Defa dbba1f4905 发布 0.1.0:通知渠道、告警规则、令牌版本与安全加固
CI / test (push) Successful in 30s
Release / release (push) Successful in 49s
2026-07-10 17:38:34 +08:00
137 changed files with 24258 additions and 4185 deletions
+10 -8
View File
@@ -23,19 +23,20 @@ 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: 下载前端最新 dist.zip 解压进嵌入目录 - name: 下载前端 dist.zip
env: env:
TOKEN: ${{ secrets.BUILD_TOKEN }} TOKEN: ${{ secrets.BUILD_TOKEN }}
run: | run: |
DASH_TAG=$(curl -fsSL -H "Authorization: token ${TOKEN}" \ DASH_TAG=$(head -1 DASH_VERSION)
"${{ gitea.server_url }}/api/v1/repos/${DASH_REPO}/releases/latest" | jq -r '.tag_name') BASE="${{ gitea.server_url }}/${DASH_REPO}/releases/download/${DASH_TAG}"
curl -fsSL -H "Authorization: token ${TOKEN}" \ curl -fsSL -H "Authorization: token ${TOKEN}" -o dist.zip "${BASE}/dist.zip"
-o dist.zip "${{ gitea.server_url }}/${DASH_REPO}/releases/download/${DASH_TAG}/dist.zip" curl -fsSL -H "Authorization: token ${TOKEN}" -o dist.zip.sha256 "${BASE}/dist.zip.sha256"
sha256sum -c dist.zip.sha256
rm -rf internal/webui/dist && mkdir -p internal/webui/dist rm -rf internal/webui/dist && mkdir -p internal/webui/dist
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 \
@@ -46,8 +47,9 @@ jobs:
mkdir -p assets mkdir -p assets
mv output/linux_amd64/oci-portal-server assets/oci-portal-server-linux-amd64 mv output/linux_amd64/oci-portal-server assets/oci-portal-server-linux-amd64
mv output/linux_arm64/oci-portal-server assets/oci-portal-server-linux-arm64 mv output/linux_arm64/oci-portal-server assets/oci-portal-server-linux-arm64
(cd assets && sha256sum * > SHA256SUMS)
- name: 构建并推送多平台镜像(与上一步共享构建缓存) - name: 构建并推送多平台镜像
env: env:
TOKEN: ${{ secrets.BUILD_TOKEN }} TOKEN: ${{ secrets.BUILD_TOKEN }}
run: | run: |
@@ -65,7 +67,7 @@ jobs:
awk -v ver="${TAG#v}" '$0 ~ "^## \\[" ver "\\]" {flag=1; next} /^## \[/ && flag {exit} flag {print}' CHANGELOG.md > release-notes.md awk -v ver="${TAG#v}" '$0 ~ "^## \\[" ver "\\]" {flag=1; next} /^## \[/ && flag {exit} flag {print}' CHANGELOG.md > release-notes.md
[ -s release-notes.md ] || echo "Release ${TAG}" > release-notes.md [ -s release-notes.md ] || echo "Release ${TAG}" > release-notes.md
- name: 创建 Release 并上传二进制附件(重跑幂等,同名附件自动替换) - name: 创建 Release 并上传二进制附件
uses: https://gitea.com/actions/gitea-release-action@v1 uses: https://gitea.com/actions/gitea-release-action@v1
with: with:
token: ${{ secrets.BUILD_TOKEN }} token: ${{ secrets.BUILD_TOKEN }}
+9 -4
View File
@@ -10,7 +10,7 @@ permissions:
env: env:
BUILDX_NO_DEFAULT_ATTESTATIONS: "1" BUILDX_NO_DEFAULT_ATTESTATIONS: "1"
DASH_DOWNLOAD: https://github.com/wangdefaa/oci-portal-dash/releases/latest/download/dist.zip DASH_REPO: wangdefaa/oci-portal-dash
IMAGE: ghcr.io/wangdefaa/oci-portal IMAGE: ghcr.io/wangdefaa/oci-portal
jobs: jobs:
@@ -21,14 +21,18 @@ jobs:
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
- name: 下载前端最新 dist.zip 解压进嵌入目录 - name: 下载前端 dist.zip
run: | run: |
curl -fsSL -o dist.zip "$DASH_DOWNLOAD" DASH_TAG=$(head -1 DASH_VERSION)
BASE="https://github.com/${DASH_REPO}/releases/download/${DASH_TAG}"
curl -fsSL -o dist.zip "${BASE}/dist.zip"
curl -fsSL -o dist.zip.sha256 "${BASE}/dist.zip.sha256"
sha256sum -c dist.zip.sha256
rm -rf internal/webui/dist && mkdir -p internal/webui/dist rm -rf internal/webui/dist && mkdir -p internal/webui/dist
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: |
BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
echo "BUILD_TIME=${BUILD_TIME}" >> "$GITHUB_ENV" echo "BUILD_TIME=${BUILD_TIME}" >> "$GITHUB_ENV"
@@ -41,6 +45,7 @@ jobs:
mkdir -p assets mkdir -p assets
mv output/linux_amd64/oci-portal-server assets/oci-portal-server-linux-amd64 mv output/linux_amd64/oci-portal-server assets/oci-portal-server-linux-amd64
mv output/linux_arm64/oci-portal-server assets/oci-portal-server-linux-arm64 mv output/linux_arm64/oci-portal-server assets/oci-portal-server-linux-arm64
(cd assets && sha256sum * > SHA256SUMS)
ls -lh assets/ ls -lh assets/
- name: 提取 CHANGELOG 版本段作为 Release 描述 - name: 提取 CHANGELOG 版本段作为 Release 描述
+1 -1
View File
@@ -1 +1 @@
0.6.5 0.6.6
+2 -2
View File
@@ -1,6 +1,6 @@
# Backend Development Guidelines(oci-portal 后端规范) # Backend Development Guidelines(oci-portal 后端规范)
> Go 后端(`oci-portal/`)编码规范入口。规范提炼自 Google / Uber Go Style Guide、Effective Go 与 Go 官方模块布局指南,按本项目裁剪;条目与项目约定冲突时,以本 spec 为准。迁自 docs/开发指南.md 与根目录 AGENTS.md(2026-07)。 > Go 后端(`oci-portal/`)编码规范入口。规范提炼自 Google / Uber Go Style Guide、Effective Go 与 Go 官方模块布局指南,按本项目裁剪;条目与项目约定冲突时,以本 spec 为准。
--- ---
@@ -51,7 +51,7 @@ go mod tidy # 依赖有变更时
全部 HTTP 接口带 swaggo 注释(@Summary 中文/@Tags 按域/@Param/@Success/@Router;JWT 组接口标 @Security BearerAuth)。**新增或修改接口必须同步注释**,并重新生成 spec: 全部 HTTP 接口带 swaggo 注释(@Summary 中文/@Tags 按域/@Param/@Success/@Router;JWT 组接口标 @Security BearerAuth)。**新增或修改接口必须同步注释**,并重新生成 spec:
```bash ```bash
go tool swag init -g cmd/server/main.go -o docs --parseInternal --parseDependency go tool swag init -g cmd/server/main.go -o docs --parseInternal --parseDependency --overridesFile docs/.swaggo
``` ```
生成的 `docs/` 一并提交;UI 由 `SWAGGER=1` 开启(/swagger/index.html)。同名 handler 方法(list/create/get/update/remove)分布在多个 struct 上,写注释时确认 @Router 路径与该 receiver 的真实注册一致(routes_*.go)。 生成的 `docs/` 一并提交;UI 由 `SWAGGER=1` 开启(/swagger/index.html)。同名 handler 方法(list/create/get/update/remove)分布在多个 struct 上,写注释时确认 @Router 路径与该 receiver 的真实注册一致(routes_*.go)。
+13 -13
View File
@@ -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]
+2 -1
View File
@@ -11,7 +11,8 @@
"env": { "env": {
"DATA_KEY": "dev-data-key", "DATA_KEY": "dev-data-key",
"JWT_SECRET": "dev-jwt-secret", "JWT_SECRET": "dev-jwt-secret",
"ADMIN_PASSWORD": "admin123" "ADMIN_PASSWORD": "admin123",
"GIN_MODE": "debug"
} }
}, },
{ {
+149 -2
View File
@@ -1,8 +1,155 @@
# 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.0.1] - 2026-07-09 ## [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 提升到 16openai.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 / -shmMySQL / 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 兼容面不承接,上游 400cohere embed 向量模型不受影响),模型目录按 `xai.` / `meta.` / `openai.` 厂商前缀过滤
### Fixed
- 渠道「同步模型成功但探测报错不可用」:法兰克福等区域的微调基座模型占满探测候选导致的误报(探测状态与真实可用性不符的根因)
- 租户删除:告警命中清理改子查询,日志事件数万条时不再超出 SQL 绑定变量上限导致删除失败;无法解析的任务 payload 记警告跳过并保留原任务,不再永久阻断租户删除
## [0.1.0]
### Added
- 通知渠道扩展:WebhookSlack / 飞书 / 钉钉 / 企微预设)、ntfy、Bark、SMTP 邮件与 Telegram 五渠道并存,逐渠道推送互不影响,密钥加密落库
- 审计告警规则:按租户 / 事件 / 资源 / 来源 IPCIDR in|notin)组合匹配,阈值窗口计数与冷却,命中即推送模板通知
- 撤销全部会话:`POST /auth/revoke-sessions` 使全部旧令牌立即失效,响应携带操作者的新令牌
- 租户暂停状态识别:测活经账户 capabilities 判定 `suspended`,独立于存活 / 失效展示
- 抢机成功通知补全实例明细:租户别名、区域别名、类型与配置、镜像、公网 IPv4 与 root 密码
### Changed
- 敏感身份操作(修改凭据、TOTP 开关、外部身份绑定 / 解绑、密码登录开关)成功后令牌版本递增:全部旧 JWT 立即失效,响应返回新令牌无感续期(原 204 改为 200 + token
- 管理员徽标与管理员组识别支持双候选组名(Administrators / OCI_Administrators
- Release 流程:前端产物版本由 `DASH_VERSION` 显式固定并做 sha256 校验,二进制附 `SHA256SUMS`docker-compose 示例默认只绑定 `127.0.0.1`
- README 新增「反向代理」小节(Caddy / nginx / Traefik 示例与路由前缀清单)
### Fixed
- 删除租户改为单事务清理全部关联本地数据(任务及日志、测活 / 成本快照、回传事件、告警规则、AI 渠道、Webhook 密钥),等待在途任务执行结束,迟到写入不再残留孤儿数据
### Security
- 实例 root 密码收敛:实例详情默认掩码、点击查看,串口控制台不再提供「复制 root 密码」
- JWT 令牌版本机制:凭据 / TOTP / 外部身份 / 登录策略变更即失效全部旧令牌
- HTTP 入口加固:显式读写超时与优雅关闭、请求体上限(面板 1MB / AI 网关 10MB)、登录字段长度上限、登录守卫条目数有界
- 出错响应脱敏:内部错误只返回固定文案 + requestId,完整原因写服务端日志;GORM SQL 日志参数化输出,不再记录实参
## [0.0.1]
首个版本:自托管 OCI 多租户管理面板后端。 首个版本:自托管 OCI 多租户管理面板后端。
+1
View File
@@ -0,0 +1 @@
v0.6.0
+214 -47
View File
@@ -4,16 +4,21 @@
# OCI Portal # OCI Portal
**Oracle Cloud Infrastructure 多租户管理面板** **自托管的 OCI 多租户管理面板与 GenAI 兼容网关**
![Go](https://img.shields.io/badge/Go-1.26-00ADD8?logo=go&logoColor=white) ![Release](https://img.shields.io/github/v/release/wangdefaa/oci-portal?display_name=tag)
![License](https://img.shields.io/badge/License-MIT-blue) ![Go](https://img.shields.io/badge/Go-1.26.5-00ADD8?logo=go&logoColor=white)
![Docker](https://img.shields.io/badge/Docker-amd64%20%7C%20arm64-2496ED?logo=docker&logoColor=white) ![Docker](https://img.shields.io/badge/Docker-amd64%20%7C%20arm64-2496ED?logo=docker&logoColor=white)
![License](https://img.shields.io/badge/License-MIT-blue)
本仓库为后端;前端工程见 [oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash)(构建产物嵌入本服务成单文件) [快速开始](#快速开始) · [核心能力](#核心能力) · [生产部署](#生产部署) · [AI 网关](#ai-网关) · [开发](#开发)
</div> </div>
OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和 OCI GenAI 渠道集中到一个管理界面。Vue 前端通过 `go:embed` 嵌入 Go 服务,Release 以单个二进制和多架构容器镜像交付。
本仓库为后端与发行仓库;前端源码位于 [oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash)。
## 界面预览 ## 界面预览
| 总览 | 登录 | | 总览 | 登录 |
@@ -28,78 +33,240 @@
| --- | --- | | --- | --- |
| ![AI 网关](docs/assets/screenshot-ai-gateway.png) | ![通知设置](docs/assets/screenshot-settings-notify.png) | | ![AI 网关](docs/assets/screenshot-ai-gateway.png) | ![通知设置](docs/assets/screenshot-settings-notify.png) |
## 特性 ## 核心能力
- **租户管理**多份 OCI API Key 配置集中管理,私钥/口令 AES-256-GCM 加密落库;分组、批量测活、账户类型与订阅信息识别 - **租户与区域**:集中管理多份 OCI API Key,支持分组、批量测活、账户画像、订阅区域缓存与区域切换;私钥口令使用 AES-256-GCM 加密落库
- **资源管理**:实例(含创建/电源操作/更换公网 IP/IPv6/VNIC/引导卷)、VCN/子网/安全列表、块存储、限额成本查询,多区域/多区间支持 - **计算、网络与存储**:实例创建电源操作公网 IPIPv6VNIC、串行控制台连接,VCN / 子网 / 安全列表,引导卷与块存储挂载,限额成本查询
- **抢机任务**cron 周期尝试创建实例直到成功,支持熔断与通知 - **自动化任务**抢机、租户测活、成本同步、AI 渠道探测四类 cron 任务,提供执行日志、重叠执行防护、熔断与结果通知
- **网页控制台**实例串行控制台(xterm)与 VNC(noVNC),两跳 SSH 隧道 - **网页控制台**浏览器内使用 xterm 串行终端和 noVNC,通过 OCI 控制台连接建立两跳 SSH 隧道
- **AI 网关**OpenAI / Anthropic 兼容端点转发 OCI GenAI(对话/Responses/Embeddings),号池渠道加权负载均衡、熔断探测、密钥管理与用量日志 - **身份与审计**IAM 用户、MFA、API Key、密码策略、SAML 身份提供商、通知收件人和多 Identity Domain 管理;OCI Audit 事件可经 Service Connector Hub 与 Notifications 回传,关键事件按类别通过「云端事件」通知推送
- **日志回传**OCI Audit 事件经 Connector Hub → Notifications HTTPS 订阅回传入库,一键创建链路 - **通知与安全**Telegram、Webhook、ntfy、Bark、SMTP 五类渠道;JWT、bcrypt、TOTP、OIDC / GitHub 登录、登录锁定、IP 限速、会话撤销和系统操作审计
- **租户治理**IAM 用户/MFA/API Key 管理、密码策略、身份提供商(SAML)、通知收件人 - **AI 网关**:提供 OpenAI Responses、Chat Completions、Embeddings 与 Anthropic Messages 兼容接口,支持渠道分组、加权路由、熔断探测、模型黑白名单、密钥管理和调用日志
- **安全**JWT + bcrypt、TOTP 两步验证、OIDC/GitHub 外部登录、登录锁定、IP 限速、真实 IP 头可配、系统操作审计
- **通知**Telegram 模板化推送(测活/抢机/成本/锁定等事件) ## 运行形态
```text
浏览器 / API 客户端
┌──────────────────── OCI Portal 单个 Go 进程 ────────────────────┐
│ Vue 3 静态资源(go:embed
│ /api/v1 → Gin → Service → OCI SDK │
│ /ai/v1 → 兼容转换与路由 → OCI Generative AI │
│ GORM → SQLite(默认)/ MySQL / PostgreSQLexperimental
└─────────────────────────────────────────────────────────────────┘
```
默认推荐 SQLite 单实例部署。MySQL 和 PostgreSQL 适配仍属 experimental,不应据此推断服务支持多副本并发运行。
## 快速开始 ## 快速开始
### Docker Compose(推荐)
前置条件:Docker、Docker Compose v2、OpenSSL。
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)可写。
sudo chown 65532:65532 data
docker compose up -d
docker compose ps
```
3. 查看初始管理员密码并登录:
```bash
grep '^ADMIN_PASSWORD=' .env
```
访问 `http://127.0.0.1:18888`,默认用户名为 `admin`。
> `DATA_KEY` 用于解密数据库中的 OCI 私钥、口令和渠道凭据。它只能生成一次并持续复用;丢失或更换后,已有密文无法恢复。请将 `.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
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
DATA_KEY=$(openssl rand -hex 32) JWT_SECRET=$(openssl rand -hex 32) ADMIN_PASSWORD=<初始密码> docker compose up -d DASH_TAG="$(tr -d '\r\n' < DASH_VERSION)"
DASH_BASE="https://github.com/wangdefaa/oci-portal-dash/releases/download/${DASH_TAG}"
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
``` ```
### 源码构建(单文件,含前端) 本地构建显示 `dev` 版本;Release 工作流会注入正式版本和构建时间。`internal/webui/dist` 中的真实前端产物不应提交到仓库。
```bash ## 生产部署
# 1. 获取前端产物:从前端仓库 Release 下载 dist.zip(或本地 npm run build 后拷入)
curl -fL -o dist.zip https://github.com/wangdefaa/oci-portal-dash/releases/latest/download/dist.zip 服务本身只提供 HTTP。除本机试用外,应保持服务仅监听回环地址或容器内部网络,并由 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) ### Caddy
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)" \ ```caddyfile
-o bin/oci-portal-server ./cmd/server portal.example.com {
reverse_proxy 127.0.0.1:18888
}
``` ```
> 注意:上述解压会覆盖仓库占位文件 `internal/webui/dist/index.html`,提交代码前勿把真实产物带入版本库。 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
map $http_upgrade $connection_upgrade {
default upgrade;
"" close;
}
server {
listen 443 ssl;
server_name portal.example.com;
ssl_certificate /etc/nginx/certs/portal.example.com.crt;
ssl_certificate_key /etc/nginx/certs/portal.example.com.key;
client_max_body_size 10m;
location / {
proxy_pass http://127.0.0.1:18888;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}
}
```
</details>
若前端静态文件与后端分离部署,`/api/*` 和 `/ai/*` 必须代理到后端,其余路径由 SPA 静态服务处理并回退到 `index.html`。Traefik 通过容器网络连接时应直达容器端口 `8080`,无需暴露宿主机端口。
## AI 网关
面板内置 OpenAI / Anthropic 兼容的 GenAI 网关:独立密钥鉴权(`Authorization: Bearer sk-...` 或 `x-api-key`),支持渠道分组、加权路由、熔断探测、模型黑白名单、内容日志与调用日志。
| 端点 | 定位 | 流式 |
| --- | --- | --- |
| `POST /ai/v1/responses` | OpenAI Responses,无状态主接口(xAI 服务端工具 / MCP | 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` | 文本转语音(xAI Voice | 否 |
| `POST /ai/v1/tts` | 文本转语音(xAI 官方格式) | 否 |
| `POST /ai/v1/rerank` | 文档重排(Cohere Rerank | 否 |
| `POST /ai/v1/moderations` | 内容审核(OCI Guardrails | 否 |
| `GET /ai/v1/models` | 当前密钥可见的模型列表 | 否 |
协议兼容边界、已知上游限制与逐字段兼容矩阵见 **[AI 网关文档](./docs/AI网关.md)**。
## API 与配置
### API 文档
| 路径 | 鉴权 | 用途 |
| --- | --- | --- |
| `/api/v1/*` | 登录返回的 JWT Bearer Token | 面板管理 API |
| `/ai/v1/*` | AI 网关密钥 | OpenAI / Anthropic 兼容接口 |
| `/api/v1/webhooks/oci-logs/:secret` | URL 中的独立回传密钥 | OCI Notifications 日志回传 |
OpenAPI 文件随仓库维护:[`docs/swagger.yaml`](docs/swagger.yaml) · [`docs/swagger.json`](docs/swagger.json)。
运行进程时设置 `SWAGGER=1` 可开放 `/swagger/index.html`。Swagger 默认关闭,生产环境建议仅在受控网络内按需开启。接口注释变更后必须重新生成 OpenAPI。
### 环境变量
| 变量 | 必填 | 默认值 | 说明 | | 变量 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | | --- | :---: | --- | --- |
| `DATA_KEY` | 是 | | 敏感字段加密主密钥(更换后已入库密文无法解密) | | `DATA_KEY` | 是 | | 敏感字段加密主密钥;必须持久保存,不能随意轮换 |
| `JWT_SECRET` | 是 | | 登录令牌签名密钥 | | `JWT_SECRET` | 是 | | JWT 签名密钥;更换会使已有登录令牌失效 |
| `ADMIN_USERNAME` | 否 | `admin` | 初始管理员用户名 | | `ADMIN_USERNAME` | 否 | `admin` | 初始管理员用户名 |
| `ADMIN_PASSWORD` | 首次启动 | | 仅在用户不存在时创建;已存在不重置 | | `ADMIN_PASSWORD` | 首次启动 | | 仅在数据库无用户时创建管理员,不会重置已有密码 |
| `ADDR` | 否 | `:8080` | HTTP 监听地址 | | `ADDR` | 否 | `:8080` | HTTP 监听地址 |
| `DB_DRIVER` | 否 | `sqlite` | `sqlite` / `mysql` / `postgres`后两者 experimental | | `DB_DRIVER` | 否 | `sqlite` | `sqlite` / `mysql` / `postgres`后两者 experimental |
| `DB_DSN` | 外部库时是 | 无 | MySQL`parseTime=True`PostgreSQL 标准 DSN | | `DB_PATH` | SQLite | `oci-portal.db` | SQLite 文件路径 |
| `DB_PATH` | 否 | `oci-portal.db` | SQLite 文件路径 | | `DB_DSN` | 外部数据库 | — | MySQL 需 `parseTime=True`;不要在日志或文档中暴露凭据 |
| `PUBLIC_URL` | 否 | | 面板公网基址,日志回传一键创建链路用 | | `PUBLIC_URL` | 否 | | 面板公网基址,作为 OAuth 回调和日志回传引导的回退值 |
| `HTTPS_PROXY` | 否 | 无 | 出站代理(如 Telegram 通知走代理) | | `TZ` | 否 | 系统时区 | cron 表达式的解释时区;容器示例使用 `Asia/Shanghai` |
| `TZ` | 否 | 系统 | cron 表达式解释时区(容器内建议显式设置,二进制已嵌 tzdata) | | `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | 否 | | Go 标准出站代理变量;面板内显式代理配置优先用于对应业务 |
| `SWAGGER` | 否 | 关 | `1` 时开放 `/swagger/index.html` API 文档(生产建议按需临时开启) | | `SWAGGER` | 否 | 关 | 设为 `1` 时开放 Swagger UI |
| `GIN_MODE` | 否 | `release` | `debug` / `release` |
## 升级与备份
- 升级前同时备份 `.env` 和数据库;SQLite Compose 部署的数据文件为 `data/oci-portal.db`
- 保持 `DATA_KEY` 不变;只恢复数据库而没有原密钥,敏感字段无法解密
- 服务启动时会自动执行数据库迁移;跨版本升级前先阅读 [CHANGELOG](CHANGELOG.md)
- Compose 部署使用 `docker compose pull && docker compose up -d` 更新镜像
- OCI API Key 应遵循最小权限原则;生产环境保持 Swagger 关闭并限制管理面访问来源
## 开发 ## 开发
```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
+42 -1
View File
@@ -9,12 +9,15 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"os/signal"
"syscall"
"time" "time"
// 纯 Go 时区数据(+~450KB):distroless/scratch 容器无 tzdata, // 纯 Go 时区数据(+~450KB):distroless/scratch 容器无 tzdata,
// 嵌入后 TZ 环境变量即可让 cron 按本地时区解释 // 嵌入后 TZ 环境变量即可让 cron 按本地时区解释
_ "time/tzdata" _ "time/tzdata"
"github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
// swag 生成的 OpenAPI spec(init 时注册,SWAGGER=1 的 /swagger 路由消费) // swag 生成的 OpenAPI spec(init 时注册,SWAGGER=1 的 /swagger 路由消费)
@@ -35,6 +38,12 @@ import (
// @in header // @in header
// @name Authorization // @name Authorization
// @description 登录接口返回的 JWT,格式: Bearer <token> // @description 登录接口返回的 JWT,格式: Bearer <token>
func init() {
if os.Getenv("GIN_MODE") == "" {
gin.SetMode(gin.ReleaseMode)
}
}
func main() { func main() {
healthcheck := flag.Bool("healthcheck", false, "探活本机服务后退出:健康 0,异常 1(容器 HEALTHCHECK 用)") healthcheck := flag.Bool("healthcheck", false, "探活本机服务后退出:健康 0,异常 1(容器 HEALTHCHECK 用)")
flag.Parse() flag.Parse()
@@ -126,6 +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)
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 {
@@ -137,5 +147,36 @@ 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()
return api.NewRouter(auth, oauth, ociConfigs, tasks, console, settings, notifier, systemLogs, logEvents, proxies, aiGateway).Run(cfg.Addr) // 「关于」页存储指标需知数据库形态
api.SetAboutRuntime(cfg.DBDriver, cfg.DBPath)
router := api.NewRouter(auth, oauth, ociConfigs, tasks, console, settings, notifier, systemLogs, logEvents, proxies, aiGateway)
return serveHTTP(cfg.Addr, router)
}
// serveHTTP 以显式超时与请求头上限启动 HTTP 服务,并在 SIGINT/SIGTERM 时
// 优雅关闭(10 秒排空);防慢连接(Slowloris)与超大头耗尽资源。
// WriteTimeout 置 0:AI 网关流式响应无固定上界,WebSocket 为 hijack 连接
// 本就不受 net/http 超时管控;读侧超时已覆盖慢请求风险。
func serveHTTP(addr string, handler http.Handler) error {
srv := &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 60 * time.Second,
IdleTimeout: 2 * time.Minute,
MaxHeaderBytes: 64 << 10,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() { errCh <- srv.ListenAndServe() }()
select {
case err := <-errCh:
return err
case <-ctx.Done():
log.Println("[info] shutting down http server")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}
} }
+3 -5
View File
@@ -1,9 +1,10 @@
services: services:
oci-portal: oci-portal:
image: ghcr.io/wangdefaa/oci-portal:latest image: ghcr.io/wangdefaa/oci-portal:latest
container_name: oci-portal
restart: unless-stopped restart: unless-stopped
ports: ports:
- "18888:8080" - "127.0.0.1:18888:8080"
environment: environment:
DB_PATH: /data/oci-portal.db DB_PATH: /data/oci-portal.db
DATA_KEY: ${DATA_KEY:?DATA_KEY is required} DATA_KEY: ${DATA_KEY:?DATA_KEY is required}
@@ -11,7 +12,4 @@ services:
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
TZ: Asia/Shanghai TZ: Asia/Shanghai
volumes: volumes:
- oci-portal-data:/data - ./data:/data
volumes:
oci-portal-data:
+6
View File
@@ -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
+528
View File
@@ -0,0 +1,528 @@
# AI 网关
> [!NOTE]
> OCI Portal AI 网关将多个 OCI GenAI 渠道统一为 OpenAI、Anthropic 与 xAI
> 兼容接口,并集中处理鉴权、模型访问控制、渠道调度和协议适配。
>
> 路径、参数与响应结构以 [Swagger YAML](swagger.yaml) 或运行时 Swagger UI
> 为准;协议差异、兼容改写和实测边界以本文为准。
**兼容快照:2026-07-15**
## 快速导航
- [快速接入](#quick-start)
- [端点一览](#endpoints)
- [路由与全局行为](#routing)
- [Codex 接入](#codex)
- [已知限制](#limitations)
- [字段兼容矩阵](#compatibility)
- [实现索引](#implementation)
<a id="quick-start"></a>
## 快速接入
### 基础地址与鉴权
```text
Base URL: https://<网关地址>/ai/v1
```
网关密钥在管理面板中创建,支持以下任一请求头:
```http
Authorization: Bearer sk-...
```
```http
x-api-key: sk-...
```
密钥可绑定渠道分组和模型白名单。全局模型黑名单会同时作用于模型列表、
请求路由和探测候选;开启「过滤弃用模型」后,OCI 已宣布弃用的模型也会从
列表与路由中移除。
可先用模型列表验证地址与密钥:
```bash
curl "https://<网关地址>/ai/v1/models" \
-H "Authorization: Bearer $OCI_PORTAL_KEY"
```
<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**
- Embeddings、TTS、Rerank 与 Moderations 使用各自专用端点
<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 |
<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>
## 已知限制
### 超大流式请求
上游流式断流有两个独立触发维度,状态不同:
> [!WARNING]
> **instructions + tools 合计超过约 64.5 KB** 的流式请求,上游会在推理阶段
> 静默断连(纯 EOF,无 error / 终态事件);同请求非流式总是成功,`input`
> 正文完全不计入。2026-07-13 定位(字节级二分),**2026-07-16 复测仍存在**
> 70.4 KB 断 / 59.7 KB 过,ChicagoAPI Key 与签名行为一致)。
> [!NOTE]
> **完整请求体超过约 82 KB**(含 input)的纯体积断流(2026-07-15 定位)
> **已被上游修复**2026-07-16 复核 83 KB、真实 codex 形态 104.5 KB、200 KB、
> 400 KB 流式均正常完成(Chicago 与 Phoenix 两区、签名与 API Key 两路径对照)。
网关当前行为:
- **Chat Completions / Messages**:若上游在客户端收到任何内容前断流,自动用
非流式重做,并合成对应 chunk / event 序列(可兜住 64.5 KB 断流)
- **Responses**:直通协议中途无法透明重试(客户端已收到事件)。流式保险丝按
`instructions + tools` 字节和判定:超过阈值时预防性改非流式上游 + 合成最小
SSE 事件序列(`response.created``response.output_item.done`
`response.completed`),语义保留但无增量输出。默认开、60 KB,可在
**设置 → AI → 流式保险丝** 调整或关闭
- **已开始输出的流**:不能透明重试;调用日志会记录提前终止,客户端可能只拿到
部分事件
### grok 服务端搜索工具默认注入
`xai.` 前缀模型的 Responses 请求,网关按开关默认注入 `web_search` /
`x_search` 工具;请求 tools 已包含同名工具时保持原样,不覆盖参数。默认双开,
可在 **设置 → AI → grok 服务端搜索工具** 关闭。
### ZDR 与文件输入
Responses 的 `input_file` 内容块(`file_url` / `file_data`)实测会被上游拒绝:
```text
File content is currently unsupported for ZDR customers
```
网关强制 `store:false`,属于 ZDR 请求形态,因此当前不能通过该端点上传或引用文件。
### 规格与实测边界
本项目提供的是兼容接口,而不是 OpenAI、Anthropic 或 xAI 协议的完整实现。
部分 OCI OpenAI 兼容行为来自实测,未见 Oracle 文档合同,可能随上游调整。
<a id="compatibility"></a>
## 字段兼容矩阵
矩阵基线:
- [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)
| 标记 | 含义 |
| :---: | --- |
| ✅ | 网关直接支持 |
| ➡️ | 原样保留或透传;是否生效由 OCI 上游决定 |
| 🔄 | 网关执行字段或协议转换后支持 |
| ◐ | 部分支持、存在前置条件或语义降级 |
### 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 字节和触发预防性非流式回退(保险丝,默认开 60 KB,见[已知限制](#limitations) |
| 其余标准与未知顶层字段 | ➡️ | `context_management``include``metadata``prompt``prompt_cache_key``service_tier``truncation``user` 等均保留,由 OCI 决定是否接受 |
</details>
<details open>
<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 完全一致
### 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 错误事件
### 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` 事件
### 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` 数组,不支持流式。
### 语音生成
两个端点最终使用同一 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 用量口径,调用日志只记时延与渠道
### 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 用量口径。
### 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`
### 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 和实测结果为准。
+4170 -321
View File
File diff suppressed because it is too large Load Diff
+4170 -321
View File
File diff suppressed because it is too large Load Diff
+2786 -311
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
+7
View File
@@ -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 或块数组)拍平为文本。
+5
View File
@@ -0,0 +1,5 @@
package aiwire
// AnyJSON 仅供 swagger 文档渲染:经 .swaggo 全局替换,代表任意 JSON 值
// (json.RawMessage 与 string/数组联合类型),运行时代码不使用。
type AnyJSON struct{}
+63
View File
@@ -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"`
}
+94 -121
View File
@@ -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 在原始值为数组时填充
Parts []ContentPart Parts []ContentPart
// isArray 记录原始形态,序列化时保形 IsArray bool
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"`
}
+28
View File
@@ -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"`
}
+13 -33
View File
@@ -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"`
}
+52 -2
View File
@@ -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,13 +15,22 @@ 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) {
@@ -27,5 +39,43 @@ func about(c *gin.Context) {
"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
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !unix
package api
import "time"
// processCPUTime 非 unix 平台无 getrusage,CPU 均值退化为 0。
func processCPUTime() time.Duration { return 0 }
+57
View File
@@ -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)
}
}
+18
View File
@@ -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())
}
+213 -12
View File
@@ -6,6 +6,9 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
_ "oci-portal/internal/aiwire" // swagger 注解引用
_ "oci-portal/internal/model" // swagger 注解引用
"oci-portal/internal/service" "oci-portal/internal/service"
) )
@@ -18,7 +21,7 @@ type aiAdminHandler struct {
// @Summary ---- 密钥 ---- // @Summary ---- 密钥 ----
// @Tags AI 管理 // @Tags AI 管理
// @Success 200 {object} map[string]any // @Success 200 {object} itemsResponse[model.AiKey]
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/ai-keys [get] // @Router /api/v1/ai-keys [get]
func (h *aiAdminHandler) listKeys(c *gin.Context) { func (h *aiAdminHandler) listKeys(c *gin.Context) {
@@ -35,7 +38,7 @@ func (h *aiAdminHandler) listKeys(c *gin.Context) {
// @Summary 生成密钥 // @Summary 生成密钥
// @Tags AI 管理 // @Tags AI 管理
// @Param body body object true "请求体(见接口说明)" // @Param body body object true "请求体(见接口说明)"
// @Success 201 {object} map[string]any // @Success 201 {object} aiKeyCreateResponse "key 为明文密钥,仅本次返回"
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/ai-keys [post] // @Router /api/v1/ai-keys [post]
func (h *aiAdminHandler) createKey(c *gin.Context) { func (h *aiAdminHandler) createKey(c *gin.Context) {
@@ -43,12 +46,13 @@ func (h *aiAdminHandler) createKey(c *gin.Context) {
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
@@ -72,12 +76,13 @@ func (h *aiAdminHandler) updateKey(c *gin.Context) {
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 +113,7 @@ func (h *aiAdminHandler) deleteKey(c *gin.Context) {
// @Tags AI 管理 // @Tags AI 管理
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @Param body body object true "请求体(见接口说明)" // @Param body body object true "请求体(见接口说明)"
// @Success 200 {object} map[string]any // @Success 200 {object} model.AiKey
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/ai-keys/{id}/content-log [put] // @Router /api/v1/ai-keys/{id}/content-log [put]
func (h *aiAdminHandler) updateKeyContentLog(c *gin.Context) { func (h *aiAdminHandler) updateKeyContentLog(c *gin.Context) {
@@ -135,7 +140,7 @@ func (h *aiAdminHandler) updateKeyContentLog(c *gin.Context) {
// //
// @Summary 分页查询内容日志 // @Summary 分页查询内容日志
// @Tags AI 管理 // @Tags AI 管理
// @Success 200 {object} map[string]any // @Success 200 {object} pagedResponse[model.AiCallLog]
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/ai-content-logs [get] // @Router /api/v1/ai-content-logs [get]
func (h *aiAdminHandler) listContentLogs(c *gin.Context) { func (h *aiAdminHandler) listContentLogs(c *gin.Context) {
@@ -155,7 +160,7 @@ func (h *aiAdminHandler) listContentLogs(c *gin.Context) {
// @Summary ---- 渠道 ---- // @Summary ---- 渠道 ----
// @Tags AI 管理 // @Tags AI 管理
// @Success 200 {object} map[string]any // @Success 200 {object} itemsResponse[model.AiChannel]
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/ai-channels [get] // @Router /api/v1/ai-channels [get]
func (h *aiAdminHandler) listChannels(c *gin.Context) { func (h *aiAdminHandler) listChannels(c *gin.Context) {
@@ -170,7 +175,7 @@ func (h *aiAdminHandler) listChannels(c *gin.Context) {
// @Summary 创建 AI 渠道 // @Summary 创建 AI 渠道
// @Tags AI 管理 // @Tags AI 管理
// @Param body body service.ChannelInput true "请求体" // @Param body body service.ChannelInput true "请求体"
// @Success 201 {object} map[string]any // @Success 201 {object} model.AiChannel
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/ai-channels [post] // @Router /api/v1/ai-channels [post]
func (h *aiAdminHandler) createChannel(c *gin.Context) { func (h *aiAdminHandler) createChannel(c *gin.Context) {
@@ -234,7 +239,7 @@ func (h *aiAdminHandler) deleteChannel(c *gin.Context) {
// @Summary 触发探测:服务可见性 + 模型同步 + 配额试调,返回更新后的渠道 // @Summary 触发探测:服务可见性 + 模型同步 + 配额试调,返回更新后的渠道
// @Tags AI 管理 // @Tags AI 管理
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any // @Success 200 {object} model.AiChannel
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/ai-channels/{id}/probe [post] // @Router /api/v1/ai-channels/{id}/probe [post]
func (h *aiAdminHandler) probeChannel(c *gin.Context) { func (h *aiAdminHandler) probeChannel(c *gin.Context) {
@@ -253,7 +258,7 @@ func (h *aiAdminHandler) probeChannel(c *gin.Context) {
// @Summary 同步渠道模型缓存 // @Summary 同步渠道模型缓存
// @Tags AI 管理 // @Tags AI 管理
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any // @Success 200 {object} itemsResponse[model.AiModelCache]
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/ai-channels/{id}/sync-models [post] // @Router /api/v1/ai-channels/{id}/sync-models [post]
func (h *aiAdminHandler) syncChannelModels(c *gin.Context) { func (h *aiAdminHandler) syncChannelModels(c *gin.Context) {
@@ -269,11 +274,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 +339,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 +421,92 @@ 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"`
}
// currentAiSettings 汇总网关运行时设置为响应体。
func (h *aiAdminHandler) currentAiSettings() aiSettingsResponse {
guardOn, guardKB := h.gw.StreamGuard()
web, x := h.gw.GrokSearch()
return aiSettingsResponse{
FilterDeprecated: h.gw.FilterDeprecated(),
StreamGuardEnabled: guardOn,
StreamGuardKB: guardKB,
GrokWebSearch: web,
GrokXSearch: x,
}
}
// aiSettings 返回 AI 网关全局设置。
//
// @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"
// @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
}
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
}
c.JSON(http.StatusOK, h.currentAiSettings())
}
+461 -184
View File
@@ -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)
writeAnthEvents(c, evs)
}); err != nil {
entry.ErrMsg = err.Error() entry.ErrMsg = err.Error()
} }
break // 上游断流且客户端尚未收到任何事件(OCI 兼容面对大请求 + 推理模型的
} // 流式通道会在 reasoning 阶段掐断):降级非流式重做,结果按事件序列推送
writeAnthEvents(c, st.Feed(chunk)) if emitted == 0 && !bridge.SawTerminal() && c.Request.Context().Err() == nil {
} if h.anthFallback(c, &entry, req) {
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() callID := h.gw.LogCall(entry)
h.maybeLogContent(c, callID, "anthropic", req.Model, true, req, nil)
return
}
}
writeAnthEvents(c, bridge.Finish())
// 上游错误事件 / 提前终止:SSE 头已发出维持 200,错误落日志可查
if msg := bridge.Err(); msg != "" && entry.ErrMsg == "" {
entry.ErrMsg = msg
}
entry.Status = http.StatusOK
entry.LatencyMs = time.Since(start).Milliseconds()
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,
for _, ev := range events { // 客户端至少拿到完整结果。
b, err := json.Marshal(ev.Data) func writeSynthSSE(c *gin.Context, payload []byte) {
events, err := service.RespSynthSSEEvents(payload)
if err != nil { if err != nil {
continue log.Printf("responses 直通: 合成 SSE 失败,降级 JSON 返回: %v", err)
c.Data(http.StatusOK, "application/json; charset=utf-8", payload)
return
} }
c.Writer.WriteString("event: " + ev.Event + "\ndata: ") sseHeaders(c)
c.Writer.Write(b) for _, ev := range events {
c.Writer.WriteString("\n\n") c.Writer.Write([]byte("data: "))
c.Writer.Write(ev)
c.Writer.Write([]byte("\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
}
+145
View File
@@ -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)
}
+230
View File
@@ -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
View File
@@ -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) {
+8 -7
View File
@@ -18,10 +18,11 @@ type authHandler struct {
} }
type loginRequest struct { type loginRequest struct {
Username string `json:"username" binding:"required"` // 字段长度上限防超长输入撑爆登录守卫与 bcrypt(S-03)
Password string `json:"password" binding:"required"` Username string `json:"username" binding:"required,max=64"`
Password string `json:"password" binding:"required,max=128"`
// 两步验证码;账号已启用 TOTP 时必填,缺失返回 428 // 两步验证码;账号已启用 TOTP 时必填,缺失返回 428
TotpCode string `json:"totpCode"` TotpCode string `json:"totpCode" binding:"max=8"`
} }
// login 校验用户名密码(与可选 TOTP)后签发 JWT。 // login 校验用户名密码(与可选 TOTP)后签发 JWT。
@@ -31,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
@@ -64,7 +65,7 @@ func (h *authHandler) login(c *gin.Context) {
return return
} }
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, err)
return return
} }
h.recordLogin(c, req.Username, http.StatusOK, start) h.recordLogin(c, req.Username, http.StatusOK, start)
+73 -20
View File
@@ -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) {
@@ -81,7 +85,7 @@ func (h *authxHandler) totpActivate(c *gin.Context) {
respondError(c, err) respondError(c, err)
return return
} }
c.Status(http.StatusNoContent) h.respondFreshToken(c)
} }
// totpDisable 停用两步验证;需当前验证码或登录密码任一确认。 // totpDisable 停用两步验证;需当前验证码或登录密码任一确认。
@@ -110,7 +114,34 @@ func (h *authxHandler) totpDisable(c *gin.Context) {
respondError(c, err) respondError(c, err)
return return
} }
h.respondFreshToken(c)
}
// respondFreshToken 敏感变更后为操作者签发新令牌返回(版本已递增,
// 旧令牌全部失效);签发失败降级 204,前端按会话失效走重新登录。
func (h *authxHandler) respondFreshToken(c *gin.Context) {
token, expires, err := h.auth.IssueToken(c.Request.Context(), c.GetString(usernameKey))
if err != nil {
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
return
}
c.JSON(http.StatusOK, gin.H{"token": token, "expiresAt": expires})
}
// revokeSessions 撤销全部会话(令牌版本递增),并为当前操作者重签新令牌。
//
// @Summary 撤销全部会话
// @Tags 认证
// @Success 200 {object} tokenResponse "新 token 与 expiresAt"
// @Security BearerAuth
// @Router /api/v1/auth/revoke-sessions [post]
func (h *authxHandler) revokeSessions(c *gin.Context) {
token, expires, err := h.auth.RevokeSessions(c.Request.Context(), c.GetString(usernameKey))
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"token": token, "expiresAt": expires})
} }
// ---- 登录凭据(JWT 组内) ---- // ---- 登录凭据(JWT 组内) ----
@@ -119,7 +150,7 @@ func (h *authxHandler) totpDisable(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) {
@@ -140,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) {
@@ -149,7 +180,7 @@ func (h *authxHandler) updateCredentials(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
err := h.auth.UpdateCredentials(c.Request.Context(), c.GetString(usernameKey), req) finalName, err := h.auth.UpdateCredentials(c.Request.Context(), c.GetString(usernameKey), req)
if errors.Is(err, service.ErrCredentialConfirm) { if errors.Is(err, service.ErrCredentialConfirm) {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return return
@@ -162,7 +193,8 @@ func (h *authxHandler) updateCredentials(c *gin.Context) {
respondError(c, err) respondError(c, err)
return return
} }
c.Status(http.StatusNoContent) c.Set(usernameKey, finalName)
h.respondFreshToken(c)
} }
// updatePasswordLogin 保存密码登录禁用开关;开启需至少绑定一个外部身份。 // updatePasswordLogin 保存密码登录禁用开关;开启需至少绑定一个外部身份。
@@ -171,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) {
@@ -191,7 +223,7 @@ func (h *authxHandler) updatePasswordLogin(c *gin.Context) {
respondError(c, err) respondError(c, err)
return return
} }
c.Status(http.StatusNoContent) h.respondFreshToken(c)
} }
// ---- OAuth ---- // ---- OAuth ----
@@ -202,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())
@@ -219,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")
@@ -254,7 +286,7 @@ func (h *authxHandler) bearerUser(c *gin.Context) (string, bool) {
if len(header) < 8 || header[:7] != "Bearer " { if len(header) < 8 || header[:7] != "Bearer " {
return "", false return "", false
} }
username, err := h.auth.ParseToken(header[7:]) username, err := h.auth.ParseToken(c.Request.Context(), header[7:])
if err != nil { if err != nil {
return "", false return "", false
} }
@@ -271,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"
@@ -282,15 +316,34 @@ 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
} }
if token == "" { h.recordOauth(c, username, http.StatusOK, "", start)
// 绑定模式:回设置页安全 tab if mode == "bind" {
c.Redirect(http.StatusFound, "/settings?oauth=bound") // 绑定模式:版本已递增,新 token 经 fragment 带回设置页无感换发
c.Redirect(http.StatusFound, "/settings?oauth=bound#oauthToken="+url.QueryEscape(token))
return return
} }
// 登录模式:token 放 fragment,不进服务端日志与 Referer // 登录模式:token 放 fragment,不进服务端日志与 Referer
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} {
@@ -305,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) {
@@ -323,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) {
@@ -340,7 +393,7 @@ func (h *authxHandler) unbindIdentity(c *gin.Context) {
respondError(c, err) respondError(c, err)
return return
} }
c.Status(http.StatusNoContent) h.respondFreshToken(c)
} }
// ---- OAuth provider 设置(JWT 组内) ---- // ---- OAuth provider 设置(JWT 组内) ----
+4 -2
View File
@@ -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) {
+22 -14
View File
@@ -40,7 +40,8 @@ func boolOr(v *bool, def bool) bool {
// @Summary 身份提供商列表 // @Summary 身份提供商列表
// @Tags 租户 IAM // @Tags 租户 IAM
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any // @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @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) {
@@ -48,7 +49,7 @@ func (h *ociConfigHandler) listIdentityProviders(c *gin.Context) {
if !ok { if !ok {
return return
} }
idps, err := h.svc.IdentityProviders(c.Request.Context(), id) idps, err := h.svc.IdentityProviders(c.Request.Context(), id, c.Query("domainId"))
if err != nil { if err != nil {
respondError(c, err) respondError(c, err)
return return
@@ -59,8 +60,9 @@ func (h *ociConfigHandler) listIdentityProviders(c *gin.Context) {
// @Summary 创建身份提供商(SAML) // @Summary 创建身份提供商(SAML)
// @Tags 租户 IAM // @Tags 租户 IAM
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @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) {
@@ -73,7 +75,7 @@ func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
idp, err := h.svc.CreateIdentityProvider(c.Request.Context(), id, oci.CreateIdpInput{ idp, err := h.svc.CreateIdentityProvider(c.Request.Context(), id, c.Query("domainId"), oci.CreateIdpInput{
Name: req.Name, Name: req.Name,
Metadata: req.Metadata, Metadata: req.Metadata,
Description: req.Description, Description: req.Description,
@@ -97,9 +99,10 @@ func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
// @Summary 激活身份提供商 // @Summary 激活身份提供商
// @Tags 租户 IAM // @Tags 租户 IAM
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @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) {
@@ -114,7 +117,7 @@ func (h *ociConfigHandler) activateIdentityProvider(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
idp, err := h.svc.SetIdentityProviderEnabled(c.Request.Context(), id, c.Param("idpId"), *req.Enabled) idp, err := h.svc.SetIdentityProviderEnabled(c.Request.Context(), id, c.Query("domainId"), c.Param("idpId"), *req.Enabled)
if err != nil { if err != nil {
respondError(c, err) respondError(c, err)
return return
@@ -125,6 +128,7 @@ func (h *ociConfigHandler) activateIdentityProvider(c *gin.Context) {
// @Summary 删除身份提供商 // @Summary 删除身份提供商
// @Tags 租户 IAM // @Tags 租户 IAM
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param idpId path string true "idpId" // @Param idpId path string true "idpId"
// @Success 204 "无内容" // @Success 204 "无内容"
// @Security BearerAuth // @Security BearerAuth
@@ -134,7 +138,7 @@ func (h *ociConfigHandler) deleteIdentityProvider(c *gin.Context) {
if !ok { if !ok {
return return
} }
if err := h.svc.DeleteIdentityProvider(c.Request.Context(), id, c.Param("idpId")); err != nil { if err := h.svc.DeleteIdentityProvider(c.Request.Context(), id, c.Query("domainId"), c.Param("idpId")); err != nil {
respondError(c, err) respondError(c, err)
return return
} }
@@ -144,7 +148,8 @@ func (h *ociConfigHandler) deleteIdentityProvider(c *gin.Context) {
// @Summary 下载 SAML 元数据 // @Summary 下载 SAML 元数据
// @Tags 租户 IAM // @Tags 租户 IAM
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any // @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @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) {
@@ -152,7 +157,7 @@ func (h *ociConfigHandler) downloadSamlMetadata(c *gin.Context) {
if !ok { if !ok {
return return
} }
metadata, err := h.svc.DomainSamlMetadata(c.Request.Context(), id) metadata, err := h.svc.DomainSamlMetadata(c.Request.Context(), id, c.Query("domainId"))
if err != nil { if err != nil {
respondError(c, err) respondError(c, err)
return return
@@ -164,7 +169,8 @@ func (h *ociConfigHandler) downloadSamlMetadata(c *gin.Context) {
// @Summary 单点登录规则列表 // @Summary 单点登录规则列表
// @Tags 租户 IAM // @Tags 租户 IAM
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any // @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @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) {
@@ -172,7 +178,7 @@ func (h *ociConfigHandler) listSignOnRules(c *gin.Context) {
if !ok { if !ok {
return return
} }
rules, err := h.svc.ConsoleSignOnRules(c.Request.Context(), id) rules, err := h.svc.ConsoleSignOnRules(c.Request.Context(), id, c.Query("domainId"))
if err != nil { if err != nil {
respondError(c, err) respondError(c, err)
return return
@@ -183,8 +189,9 @@ func (h *ociConfigHandler) listSignOnRules(c *gin.Context) {
// @Summary 创建 MFA 豁免规则 // @Summary 创建 MFA 豁免规则
// @Tags 租户 IAM // @Tags 租户 IAM
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @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) {
@@ -199,7 +206,7 @@ func (h *ociConfigHandler) createMfaExemption(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
rule, err := h.svc.CreateMfaExemption(c.Request.Context(), id, req.IdentityProviderID) rule, err := h.svc.CreateMfaExemption(c.Request.Context(), id, c.Query("domainId"), req.IdentityProviderID)
if err != nil { if err != nil {
respondError(c, err) respondError(c, err)
return return
@@ -210,6 +217,7 @@ func (h *ociConfigHandler) createMfaExemption(c *gin.Context) {
// @Summary 删除 MFA 豁免规则 // @Summary 删除 MFA 豁免规则
// @Tags 租户 IAM // @Tags 租户 IAM
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param ruleId path string true "ruleId" // @Param ruleId path string true "ruleId"
// @Success 204 "无内容" // @Success 204 "无内容"
// @Security BearerAuth // @Security BearerAuth
@@ -219,7 +227,7 @@ func (h *ociConfigHandler) deleteMfaExemption(c *gin.Context) {
if !ok { if !ok {
return return
} }
if err := h.svc.DeleteMfaExemption(c.Request.Context(), id, c.Param("ruleId")); err != nil { if err := h.svc.DeleteMfaExemption(c.Request.Context(), id, c.Query("domainId"), c.Param("ruleId")); err != nil {
respondError(c, err) respondError(c, err)
return return
} }
+9 -9
View File
@@ -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 -5
View File
@@ -7,6 +7,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
_ "oci-portal/internal/model" // swagger 注解引用
"oci-portal/internal/service" "oci-portal/internal/service"
) )
@@ -20,7 +22,7 @@ type logEventHandler struct {
// @Summary 查询配置的回调地址 // @Summary 查询配置的回调地址
// @Tags 任务与日志回传 // @Tags 任务与日志回传
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any // @Success 200 {object} logWebhookStatusResponse
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/log-webhook [get] // @Router /api/v1/oci-configs/{id}/log-webhook [get]
func (h *logEventHandler) getWebhook(c *gin.Context) { func (h *logEventHandler) getWebhook(c *gin.Context) {
@@ -45,7 +47,7 @@ func (h *logEventHandler) getWebhook(c *gin.Context) {
// @Summary 生成 // @Summary 生成
// @Tags 任务与日志回传 // @Tags 任务与日志回传
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any // @Success 200 {object} service.LogWebhookInfo
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/log-webhook [post] // @Router /api/v1/oci-configs/{id}/log-webhook [post]
func (h *logEventHandler) ensureWebhook(c *gin.Context) { func (h *logEventHandler) ensureWebhook(c *gin.Context) {
@@ -87,7 +89,7 @@ func (h *logEventHandler) revokeWebhook(c *gin.Context) {
// @Tags 任务与日志回传 // @Tags 任务与日志回传
// @Param configId query int false "按配置过滤" // @Param configId query int false "按配置过滤"
// @Param q query string false "关键字" // @Param q query string false "关键字"
// @Success 200 {object} map[string]any "items 与 total" // @Success 200 {object} pagedResponse[model.LogEvent] "items 与 total"
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/log-events [get] // @Router /api/v1/log-events [get]
func (h *logEventHandler) list(c *gin.Context) { func (h *logEventHandler) list(c *gin.Context) {
@@ -111,7 +113,7 @@ func (h *logEventHandler) list(c *gin.Context) {
// @Summary 查询 OCI 侧链路状态与关键事件清单 // @Summary 查询 OCI 侧链路状态与关键事件清单
// @Tags 任务与日志回传 // @Tags 任务与日志回传
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any // @Success 200 {object} service.RelayView
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/log-relay [get] // @Router /api/v1/oci-configs/{id}/log-relay [get]
func (h *logEventHandler) getRelay(c *gin.Context) { func (h *logEventHandler) getRelay(c *gin.Context) {
@@ -132,7 +134,7 @@ func (h *logEventHandler) getRelay(c *gin.Context) {
// @Summary 一键建立回传链路 // @Summary 一键建立回传链路
// @Tags 任务与日志回传 // @Tags 任务与日志回传
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any // @Success 200 {object} service.RelayView
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/log-relay [post] // @Router /api/v1/oci-configs/{id}/log-relay [post]
func (h *logEventHandler) setupRelay(c *gin.Context) { func (h *logEventHandler) setupRelay(c *gin.Context) {
+14 -2
View File
@@ -15,7 +15,8 @@ import (
// usernameKey 是鉴权通过后写入 gin.Context 的用户名键。 // usernameKey 是鉴权通过后写入 gin.Context 的用户名键。
const usernameKey = "username" const usernameKey = "username"
// RequireAuth 校验 Authorization: Bearer 令牌,通过后把用户名放进上下文。 // RequireAuth 校验 Authorization: Bearer 令牌(签名、有效期与令牌版本),
// 通过后把用户名放进上下文。
func RequireAuth(auth *service.AuthService) gin.HandlerFunc { func RequireAuth(auth *service.AuthService) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ") token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
@@ -23,7 +24,7 @@ func RequireAuth(auth *service.AuthService) gin.HandlerFunc {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
return return
} }
username, err := auth.ParseToken(token) username, err := auth.ParseToken(c.Request.Context(), token)
if err != nil { if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
return return
@@ -33,6 +34,17 @@ func RequireAuth(auth *service.AuthService) gin.HandlerFunc {
} }
} }
// bodyLimit 给请求体套上限(S-03):超限读取由 MaxBytesReader 截断报错,
// 绑定失败返回 400;webhook 入口另有独立 256KiB 自限,不经此中间件。
func bodyLimit(n int64) gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Body != nil {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, n)
}
c.Next()
}
}
// systemLogMiddleware 把写请求(POST/PUT/DELETE)异步记入系统日志,只读请求跳过。 // systemLogMiddleware 把写请求(POST/PUT/DELETE)异步记入系统日志,只读请求跳过。
// 只记方法、路径等元数据,绝不读请求体——请求体可能含私钥 / 口令等敏感数据。 // 只记方法、路径等元数据,绝不读请求体——请求体可能含私钥 / 口令等敏感数据。
func systemLogMiddleware(logs *service.SystemLogService) gin.HandlerFunc { func systemLogMiddleware(logs *service.SystemLogService) gin.HandlerFunc {
+16 -16
View File
@@ -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) {
+36 -17
View File
@@ -1,13 +1,18 @@
package api package api
import ( import (
"crypto/rand"
"encoding/hex"
"errors" "errors"
"log"
"net/http" "net/http"
"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"
@@ -36,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) {
@@ -68,13 +73,13 @@ 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) {
items, err := h.svc.List(c.Request.Context()) items, err := h.svc.List(c.Request.Context())
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, err)
return return
} }
c.JSON(http.StatusOK, items) c.JSON(http.StatusOK, items)
@@ -83,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) {
@@ -102,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) {
@@ -136,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) {
@@ -168,6 +173,7 @@ func (h *ociConfigHandler) update(c *gin.Context) {
} }
// @Summary 删除租户配置 // @Summary 删除租户配置
// @Description 删除租户配置及其本地关联任务、快照、回传、告警和 AI 渠道数据,并撤销 Webhook 密钥;不删除 OCI 云端资源。
// @Tags 租户配置 // @Tags 租户配置
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @Success 204 "无内容" // @Success 204 "无内容"
@@ -190,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) {
@@ -212,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) {
@@ -233,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) {
@@ -258,17 +264,29 @@ func pathID(c *gin.Context) (uint, bool) {
return uint(id), true return uint(id), true
} }
// respondError 统一出错响应边界(S-08):OCI 服务错误经脱敏透出,记录缺失
// 映射 404;其余按内部错误处理——响应只含固定文案与关联 ID,完整原因
// (可能携带 SQL / DSN / 路径等内部细节)仅写服务端日志,经同一 ID 对应。
func respondError(c *gin.Context, err error) { func respondError(c *gin.Context, err error) {
status := http.StatusInternalServerError
if errors.Is(err, gorm.ErrRecordNotFound) {
status = http.StatusNotFound
}
var svcErr common.ServiceError var svcErr common.ServiceError
if errors.As(err, &svcErr) { if errors.As(err, &svcErr) {
respondOCIError(c, err, svcErr) respondOCIError(c, err, svcErr)
return return
} }
c.JSON(status, gin.H{"error": err.Error()}) if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "资源不存在"})
return
}
id := newRequestID()
log.Printf("[ERR %s] %s %s: %v", id, c.Request.Method, requestPath(c), err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器内部错误", "requestId": id})
}
// newRequestID 生成错误关联 ID(8 字节随机 hex),响应与服务端日志据此对应。
func newRequestID() string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
} }
// respondOCIError 提炼 OCI 服务错误:透传 HTTP 状态码, // respondOCIError 提炼 OCI 服务错误:透传 HTTP 状态码,
@@ -278,9 +296,10 @@ func respondOCIError(c *gin.Context, err error, svcErr common.ServiceError) {
if status < http.StatusBadRequest { if status < http.StatusBadRequest {
status = http.StatusBadGateway status = http.StatusBadGateway
} }
// 面板的 401 专属本地 JWT 失效(前端收到即登出) // 面板的 401 专属本地 JWT 失效(前端收到即登出)、429 专属本地 IP 限速
// 上游 OCI 的 401 是代理调用被拒,改用 502 透出 // (前端收到即跳 /blocked);上游 OCI 的同码是代理调用被拒/云端限流
if status == http.StatusUnauthorized { // 均改用 502 透出,body 保留 ociCode 供辨识
if status == http.StatusUnauthorized || status == http.StatusTooManyRequests {
status = http.StatusBadGateway status = http.StatusBadGateway
} }
body := gin.H{ body := gin.H{
+54
View File
@@ -0,0 +1,54 @@
package api
import (
"fmt"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
// fakeServiceError 模拟 oci-go-sdk 的 common.ServiceError,驱动 respondOCIError 分支。
type fakeServiceError struct {
status int
code string
message string
}
func (e fakeServiceError) Error() string {
return fmt.Sprintf("Error returned by service. Http Status Code: %d. Error Code: %s. Message: %s",
e.status, e.code, e.message)
}
func (e fakeServiceError) GetHTTPStatusCode() int { return e.status }
func (e fakeServiceError) GetMessage() string { return e.message }
func (e fakeServiceError) GetCode() string { return e.code }
func (e fakeServiceError) GetOpcRequestID() string { return "req-1" }
// TestRespondErrorOCIStatus 覆盖上游状态码改写规则:
// 401/429 在前端有本地专属语义(登出 / 跳 /blocked),上游同码须改写 502。
func TestRespondErrorOCIStatus(t *testing.T) {
gin.SetMode(gin.TestMode)
cases := []struct {
name string
upstream int
wantStatus int
}{
{"404 原样透传", 404, 404},
{"409 原样透传", 409, 409},
{"上游 401 改写 502", 401, 502},
{"上游 429 改写 502", 429, 502},
{"非错误码兜底 502", 200, 502},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
err := fmt.Errorf("获取流量: %w", fakeServiceError{tc.upstream, "TooManyRequests", "slow down"})
respondError(c, err)
if rec.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus)
}
})
}
}
+3 -1
View File
@@ -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
View File
@@ -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 {
+7 -5
View File
@@ -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,13 +15,13 @@ 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) {
regions, err := oci.AllRegions() regions, err := oci.AllRegions()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, err)
return return
} }
c.JSON(http.StatusOK, regions) c.JSON(http.StatusOK, regions)
@@ -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) {
+2 -1
View File
@@ -21,7 +21,8 @@ func NewRouter(auth *service.AuthService, oauth *service.OAuthService, ociConfig
accessLog := gin.LoggerWithConfig(gin.LoggerConfig{SkipQueryString: true}) accessLog := gin.LoggerWithConfig(gin.LoggerConfig{SkipQueryString: true})
r.Use(accessLog, RealIPMiddleware(settings), IPRateMiddleware(settings), gin.Recovery()) r.Use(accessLog, RealIPMiddleware(settings), IPRateMiddleware(settings), gin.Recovery())
v1 := r.Group("/api/v1") // 面板 API 请求体统一 1MB 上限(S-03);AI 网关按对话体量单独 10MB
v1 := r.Group("/api/v1", bodyLimit(1<<20))
registerAuthPublic(v1, auth, oauth, systemLogs) registerAuthPublic(v1, auth, oauth, systemLogs)
// AI 网关对外端点:独立密钥鉴权,挂在全局 Use 之后,仍受 IP 限速与 Recovery 保护 // AI 网关对外端点:独立密钥鉴权,挂在全局 Use 之后,仍受 IP 限速与 Recovery 保护
registerAiGateway(r, aiGateway) registerAiGateway(r, aiGateway)
+110 -1
View File
@@ -2,6 +2,8 @@ package api
import ( import (
"context" "context"
"encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -55,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{
@@ -88,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 {
@@ -183,3 +192,103 @@ func TestSystemLogsEndpoint(t *testing.T) {
}) })
} }
} }
// TestLoginFieldLimits 超长登录字段被绑定校验拒绝(S-03 高基数防护第一道)。
func TestLoginFieldLimits(t *testing.T) {
r, _, _ := newTestRouter(t)
longName := strings.Repeat("a", 65)
longPass := strings.Repeat("b", 129)
tests := []struct {
name string
body string
}{
{name: "用户名超长", body: `{"username":"` + longName + `","password":"x"}`},
{name: "密码超长", body: `{"username":"admin","password":"` + longPass + `"}`},
{name: "验证码超长", body: `{"username":"admin","password":"x","totpCode":"123456789"}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := doRequest(t, r, http.MethodPost, "/api/v1/auth/login", "", tt.body)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
})
}
}
// TestBodyLimitRejectsHuge 面板 API 请求体超 1MB 被拒(S-03)。
func TestBodyLimitRejectsHuge(t *testing.T) {
r, _, _ := newTestRouter(t)
huge := `{"username":"admin","password":"` + strings.Repeat("x", 2<<20) + `"}`
w := doRequest(t, r, http.MethodPost, "/api/v1/auth/login", "", huge)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400(body too large)", w.Code)
}
}
// TestRevokeSessionsEndpoint 撤销全部会话:直接调用即生效,
// 响应带新 token,旧 token 随即失效。
func TestRevokeSessionsEndpoint(t *testing.T) {
r, _, _ := newTestRouter(t)
login := doRequest(t, r, http.MethodPost, "/api/v1/auth/login", "", `{"username":"admin","password":"pass123"}`)
var sess struct{ Token string }
if err := json.Unmarshal(login.Body.Bytes(), &sess); err != nil || sess.Token == "" {
t.Fatalf("login: %s", login.Body.String())
}
w := doRequest(t, r, http.MethodPost, "/api/v1/auth/revoke-sessions", sess.Token, "")
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "token") {
t.Fatalf("revoke = %d %s", w.Code, w.Body.String())
}
w = doRequest(t, r, http.MethodGet, "/api/v1/auth/credentials", sess.Token, "")
if w.Code != http.StatusUnauthorized {
t.Errorf("撤销后旧 token 访问 = %d, want 401", w.Code)
}
}
// TestRespondErrorSanitizesInternal 内部错误边界(S-08):wrap 链细节不透传,
// 响应为固定文案 + requestId;记录缺失映射 404 固定文案。
func TestRespondErrorSanitizesInternal(t *testing.T) {
gin.SetMode(gin.TestMode)
cases := []struct {
name string
err error
wantStatus int
wantBody string // 必须出现
leaks []string // 不得出现
}{
{
name: "内部错误脱敏",
err: fmt.Errorf("query users: dial tcp 10.0.0.5:3306: dsn user:secretpw"),
wantStatus: http.StatusInternalServerError,
wantBody: "requestId",
leaks: []string{"secretpw", "10.0.0.5", "dsn", "dial tcp"},
},
{
name: "记录缺失固定文案",
err: fmt.Errorf("find oci config 5: %w", gorm.ErrRecordNotFound),
wantStatus: http.StatusNotFound,
wantBody: "资源不存在",
leaks: []string{"find oci config"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
respondError(c, tc.err)
if rec.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus)
}
body := rec.Body.String()
if !strings.Contains(body, tc.wantBody) {
t.Errorf("body %q 应包含 %q", body, tc.wantBody)
}
for _, leak := range tc.leaks {
if strings.Contains(body, leak) {
t.Errorf("body %q 泄露内部细节 %q", body, leak)
}
}
})
}
}
+13 -1
View File
@@ -10,11 +10,15 @@ import (
// 挂在全局中间件之后,仍受 IP 限速与 Recovery 保护;高频调用自有 AiCallLog。 // 挂在全局中间件之后,仍受 IP 限速与 Recovery 保护;高频调用自有 AiCallLog。
func registerAiGateway(r *gin.Engine, aiGateway *service.AiGatewayService) { func registerAiGateway(r *gin.Engine, aiGateway *service.AiGatewayService) {
aih := &aiGatewayHandler{gw: aiGateway} aih := &aiGatewayHandler{gw: aiGateway}
ai := r.Group("/ai/v1", aih.auth) ai := r.Group("/ai/v1", bodyLimit(10<<20), aih.auth)
ai.POST("/chat/completions", aih.chatCompletions) ai.POST("/chat/completions", aih.chatCompletions)
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)
} }
+5 -4
View File
@@ -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)
@@ -26,13 +26,14 @@ func registerAuthSecured(secured *gin.RouterGroup, auth *service.AuthService, oa
ax := &authxHandler{auth: auth, oauth: oauth} ax := &authxHandler{auth: auth, oauth: oauth}
secured.GET("/auth/totp", ax.totpStatus) secured.GET("/auth/totp", ax.totpStatus)
secured.POST("/auth/totp/setup", ax.totpSetup) secured.POST("/auth/totp/setup", ax.totpSetup)
secured.POST("/auth/totp/activate", ax.totpActivate)
secured.POST("/auth/totp/disable", ax.totpDisable)
secured.GET("/auth/credentials", ax.getCredentials) secured.GET("/auth/credentials", ax.getCredentials)
secured.PUT("/auth/credentials", ax.updateCredentials) secured.PUT("/auth/credentials", ax.updateCredentials)
secured.PUT("/auth/password-login", ax.updatePasswordLogin)
secured.GET("/auth/identities", ax.identities) secured.GET("/auth/identities", ax.identities)
secured.POST("/auth/totp/activate", ax.totpActivate)
secured.POST("/auth/totp/disable", ax.totpDisable)
secured.PUT("/auth/password-login", ax.updatePasswordLogin)
secured.DELETE("/auth/identities/:id", ax.unbindIdentity) secured.DELETE("/auth/identities/:id", ax.unbindIdentity)
secured.POST("/auth/revoke-sessions", ax.revokeSessions)
secured.GET("/settings/oauth", func(c *gin.Context) { ax.getOAuthSettings(c, settings) }) secured.GET("/settings/oauth", func(c *gin.Context) { ax.getOAuthSettings(c, settings) })
secured.PUT("/settings/oauth", func(c *gin.Context) { ax.updateOAuthSettings(c, settings) }) secured.PUT("/settings/oauth", func(c *gin.Context) { ax.updateOAuthSettings(c, settings) })
} }
+1
View File
@@ -109,6 +109,7 @@ func registerOciCost(g *gin.RouterGroup, h *ociConfigHandler) {
func registerOciTenantIAM(g *gin.RouterGroup, h *ociConfigHandler) { func registerOciTenantIAM(g *gin.RouterGroup, h *ociConfigHandler) {
g.GET("/oci-configs/:id/audit-events", h.getAuditEvents) g.GET("/oci-configs/:id/audit-events", h.getAuditEvents)
g.GET("/oci-configs/:id/audit-events/detail", h.getAuditEventDetail) g.GET("/oci-configs/:id/audit-events/detail", h.getAuditEventDetail)
g.GET("/oci-configs/:id/domains", h.listIdentityDomains)
g.GET("/oci-configs/:id/users", h.listTenantUsers) g.GET("/oci-configs/:id/users", h.listTenantUsers)
g.POST("/oci-configs/:id/users", h.createTenantUser) g.POST("/oci-configs/:id/users", h.createTenantUser)
g.GET("/oci-configs/:id/users/:userId", h.getTenantUserDetail) g.GET("/oci-configs/:id/users/:userId", h.getTenantUserDetail)
+4
View File
@@ -15,6 +15,9 @@ func registerSettings(secured *gin.RouterGroup, settings *service.SettingService
secured.GET("/settings/telegram", st.getTelegram) secured.GET("/settings/telegram", st.getTelegram)
secured.PUT("/settings/telegram", st.updateTelegram) secured.PUT("/settings/telegram", st.updateTelegram)
secured.POST("/settings/telegram/test", st.testTelegram) secured.POST("/settings/telegram/test", st.testTelegram)
secured.GET("/settings/notify-channels", st.listNotifyChannels)
secured.PUT("/settings/notify-channels/:type", st.updateNotifyChannel)
secured.POST("/settings/notify-channels/:type/test", st.testNotifyChannel)
secured.GET("/settings/notify-events", st.getNotifyEvents) secured.GET("/settings/notify-events", st.getNotifyEvents)
secured.PUT("/settings/notify-events", st.updateNotifyEvents) secured.PUT("/settings/notify-events", st.updateNotifyEvents)
secured.GET("/settings/notify-templates", st.listNotifyTemplates) secured.GET("/settings/notify-templates", st.listNotifyTemplates)
@@ -32,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)
} }
+85 -9
View File
@@ -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) {
@@ -80,11 +80,87 @@ func (h *settingsHandler) testTelegram(c *gin.Context) {
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
} }
// listNotifyChannels 返回全部通知渠道的脱敏视图(webhook/ntfy/bark/smtp,不含 telegram)。
//
// @Summary 返回全部通知渠道的脱敏视图
// @Tags 设置
// @Success 200 {object} itemsResponse[service.NotifyChannelView]
// @Security BearerAuth
// @Router /api/v1/settings/notify-channels [get]
func (h *settingsHandler) listNotifyChannels(c *gin.Context) {
items, err := h.svc.NotifyChannels(c.Request.Context())
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
// updateNotifyChannelRequest 是保存单渠道配置的请求体;
// secret 承载渠道密文字段(ntfy token / bark device key / smtp 密码),
// 缺省沿用已存值,空串清除。
type updateNotifyChannelRequest struct {
Enabled bool `json:"enabled"`
URL string `json:"url"`
BodyTemplate string `json:"bodyTemplate"`
Server string `json:"server"`
Topic string `json:"topic"`
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
From string `json:"from"`
To string `json:"to"`
Secret *string `json:"secret"`
}
// updateNotifyChannel 保存单渠道配置并返回全渠道最新视图;未知类型或校验失败返回 400。
//
// @Summary 保存单渠道配置并返回全渠道最新视图
// @Tags 设置
// @Param type path string true "渠道类型(webhook/ntfy/bark/smtp)"
// @Param body body updateNotifyChannelRequest true "请求体"
// @Success 200 {object} itemsResponse[service.NotifyChannelView]
// @Security BearerAuth
// @Router /api/v1/settings/notify-channels/{type} [put]
func (h *settingsHandler) updateNotifyChannel(c *gin.Context) {
var req updateNotifyChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.svc.UpdateNotifyChannel(c.Request.Context(), c.Param("type"), service.UpdateNotifyChannelInput{
Enabled: req.Enabled, URL: req.URL, BodyTemplate: req.BodyTemplate,
Server: req.Server, Topic: req.Topic, Host: req.Host, Port: req.Port,
Username: req.Username, From: req.From, To: req.To, Secret: req.Secret,
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
h.listNotifyChannels(c)
}
// testNotifyChannel 用已保存配置向指定渠道同步发送测试消息。
//
// @Summary 向指定渠道发送测试消息
// @Tags 设置
// @Param type path string true "渠道类型(webhook/ntfy/bark/smtp)"
// @Success 204 "无内容"
// @Security BearerAuth
// @Router /api/v1/settings/notify-channels/{type}/test [post]
func (h *settingsHandler) testNotifyChannel(c *gin.Context) {
if err := h.notifier.SendChannelTest(c.Request.Context(), c.Param("type")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
// getNotifyEvents 返回全部通知事件开关;键缺省(存量部署)视为开启。 // getNotifyEvents 返回全部通知事件开关;键缺省(存量部署)视为开启。
// //
// @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) {
@@ -102,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) {
@@ -122,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) {
@@ -187,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) {
@@ -204,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) {
@@ -228,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) {
@@ -245,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) {
+5 -3
View File
@@ -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) {
+176
View File
@@ -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"`
}
+2 -1
View File
@@ -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
View File
@@ -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})
} }
+70 -39
View File
@@ -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) {
@@ -67,13 +67,15 @@ func (h *ociConfigHandler) costs(c *gin.Context) {
// ---- 租户审计日志 ---- // ---- 租户审计日志 ----
// getAuditEvents 实时查询租户 OCI 审计事件:hours 首查(缺省 24); // getAuditEvents 批式懒加载查询审计事件:cursor 为空自当前时刻首查,
// start/end/page 为截断后的同窗续查;窗口越界或非法返回 400 // 非空从上次响应游标继续向更早回溯;limit 单批目标条数(缺省 100,上限 200)
// //
// @Summary 实时查询租户 OCI 审计事件:hours 首查 // @Summary 批式懒加载查询租户 OCI 审计事件
// @Tags 租户 IAM // @Tags 租户 IAM
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any // @Param cursor query string false "续查游标(上次响应原样带回)"
// @Param limit query int false "单批目标条数,缺省 100,上限 200"
// @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) {
@@ -81,13 +83,10 @@ func (h *ociConfigHandler) getAuditEvents(c *gin.Context) {
if !ok { if !ok {
return return
} }
hours, _ := strconv.Atoi(c.DefaultQuery("hours", "24")) limit, _ := strconv.Atoi(c.Query("limit"))
q := service.AuditQuery{ q := service.AuditQuery{Region: c.Query("region"), Cursor: c.Query("cursor"), Limit: limit}
Region: c.Query("region"), Hours: hours,
Start: c.Query("start"), End: c.Query("end"), Page: c.Query("page"),
}
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.ErrInvalidAuditHours) || errors.Is(err, service.ErrInvalidAuditWindow) { if errors.Is(err, service.ErrInvalidAuditCursor) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -104,7 +103,7 @@ func (h *ociConfigHandler) getAuditEvents(c *gin.Context) {
// @Summary 按 eventId 取回原始事件 JSON:缓存命中秒开, // @Summary 按 eventId 取回原始事件 JSON:缓存命中秒开,
// @Tags 租户 IAM // @Tags 租户 IAM
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any // @Success 200 {object} rawDetailResponse "raw 为事件原文 JSON"
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/audit-events/detail [get] // @Router /api/v1/oci-configs/{id}/audit-events/detail [get]
func (h *ociConfigHandler) getAuditEventDetail(c *gin.Context) { func (h *ociConfigHandler) getAuditEventDetail(c *gin.Context) {
@@ -143,10 +142,30 @@ type createTenantUserRequest struct {
AddToAdminGroup bool `json:"addToAdminGroup"` AddToAdminGroup bool `json:"addToAdminGroup"`
} }
// @Summary 租户身份域列表
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Success 200 {array} oci.IdentityDomain
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/domains [get]
func (h *ociConfigHandler) listIdentityDomains(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
domains, err := h.svc.IdentityDomains(c.Request.Context(), id)
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, domains)
}
// @Summary 租户 IAM 用户列表 // @Summary 租户 IAM 用户列表
// @Tags 租户 IAM // @Tags 租户 IAM
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any // @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @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) {
@@ -154,7 +173,7 @@ func (h *ociConfigHandler) listTenantUsers(c *gin.Context) {
if !ok { if !ok {
return return
} }
users, err := h.svc.TenantUsers(c.Request.Context(), id) users, err := h.svc.TenantUsers(c.Request.Context(), id, c.Query("domainId"))
if err != nil { if err != nil {
respondError(c, err) respondError(c, err)
return return
@@ -165,8 +184,9 @@ func (h *ociConfigHandler) listTenantUsers(c *gin.Context) {
// @Summary 租户用户详情 // @Summary 租户用户详情
// @Tags 租户 IAM // @Tags 租户 IAM
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @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) {
@@ -174,7 +194,7 @@ func (h *ociConfigHandler) getTenantUserDetail(c *gin.Context) {
if !ok { if !ok {
return return
} }
detail, err := h.svc.TenantUserDetail(c.Request.Context(), id, c.Param("userId")) detail, err := h.svc.TenantUserDetail(c.Request.Context(), id, c.Query("domainId"), c.Param("userId"))
if err != nil { if err != nil {
respondError(c, err) respondError(c, err)
return return
@@ -185,8 +205,9 @@ func (h *ociConfigHandler) getTenantUserDetail(c *gin.Context) {
// @Summary 创建租户 IAM 用户 // @Summary 创建租户 IAM 用户
// @Tags 租户 IAM // @Tags 租户 IAM
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @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) {
@@ -199,7 +220,7 @@ func (h *ociConfigHandler) createTenantUser(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
user, err := h.svc.CreateTenantUser(c.Request.Context(), id, oci.CreateTenantUserInput{ user, err := h.svc.CreateTenantUser(c.Request.Context(), id, c.Query("domainId"), oci.CreateTenantUserInput{
Name: req.Name, Name: req.Name,
Description: req.Description, Description: req.Description,
Email: req.Email, Email: req.Email,
@@ -229,9 +250,10 @@ type updateTenantUserRequest struct {
// @Summary 更新租户 IAM 用户 // @Summary 更新租户 IAM 用户
// @Tags 租户 IAM // @Tags 租户 IAM
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @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) {
@@ -244,7 +266,7 @@ func (h *ociConfigHandler) updateTenantUser(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
user, err := h.svc.UpdateTenantUser(c.Request.Context(), id, c.Param("userId"), oci.UpdateTenantUserInput{ user, err := h.svc.UpdateTenantUser(c.Request.Context(), id, c.Query("domainId"), c.Param("userId"), oci.UpdateTenantUserInput{
Description: req.Description, Description: req.Description,
Email: req.Email, Email: req.Email,
GivenName: req.GivenName, GivenName: req.GivenName,
@@ -262,6 +284,7 @@ func (h *ociConfigHandler) updateTenantUser(c *gin.Context) {
// @Summary 删除租户 IAM 用户 // @Summary 删除租户 IAM 用户
// @Tags 租户 IAM // @Tags 租户 IAM
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param userId path string true "userId" // @Param userId path string true "userId"
// @Success 204 "无内容" // @Success 204 "无内容"
// @Security BearerAuth // @Security BearerAuth
@@ -271,7 +294,7 @@ func (h *ociConfigHandler) deleteTenantUser(c *gin.Context) {
if !ok { if !ok {
return return
} }
if err := h.svc.DeleteTenantUser(c.Request.Context(), id, c.Param("userId")); err != nil { if err := h.svc.DeleteTenantUser(c.Request.Context(), id, c.Query("domainId"), c.Param("userId")); err != nil {
respondError(c, err) respondError(c, err)
return return
} }
@@ -281,8 +304,9 @@ func (h *ociConfigHandler) deleteTenantUser(c *gin.Context) {
// @Summary 重置租户用户密码 // @Summary 重置租户用户密码
// @Tags 租户 IAM // @Tags 租户 IAM
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @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) {
@@ -290,7 +314,7 @@ func (h *ociConfigHandler) resetTenantUserPassword(c *gin.Context) {
if !ok { if !ok {
return return
} }
password, err := h.svc.ResetTenantUserPassword(c.Request.Context(), id, c.Param("userId")) password, err := h.svc.ResetTenantUserPassword(c.Request.Context(), id, c.Query("domainId"), c.Param("userId"))
if err != nil { if err != nil {
respondError(c, err) respondError(c, err)
return return
@@ -301,8 +325,9 @@ func (h *ociConfigHandler) resetTenantUserPassword(c *gin.Context) {
// @Summary 清除用户 MFA 设备 // @Summary 清除用户 MFA 设备
// @Tags 租户 IAM // @Tags 租户 IAM
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @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) {
@@ -310,7 +335,7 @@ func (h *ociConfigHandler) deleteTenantUserMfa(c *gin.Context) {
if !ok { if !ok {
return return
} }
deleted, err := h.svc.DeleteTenantUserMfa(c.Request.Context(), id, c.Param("userId")) deleted, err := h.svc.DeleteTenantUserMfa(c.Request.Context(), id, c.Query("domainId"), c.Param("userId"))
if err != nil { if err != nil {
respondError(c, err) respondError(c, err)
return return
@@ -322,7 +347,7 @@ func (h *ociConfigHandler) deleteTenantUserMfa(c *gin.Context) {
// @Tags 租户 IAM // @Tags 租户 IAM
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @Param userId path string true "userId" // @Param userId path string true "userId"
// @Success 200 {object} map[string]any // @Success 200 {object} deletedApiKeysResponse
// @Security BearerAuth // @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/users/{userId}/api-keys [delete] // @Router /api/v1/oci-configs/{id}/users/{userId}/api-keys [delete]
func (h *ociConfigHandler) deleteTenantUserApiKeys(c *gin.Context) { func (h *ociConfigHandler) deleteTenantUserApiKeys(c *gin.Context) {
@@ -344,7 +369,8 @@ func (h *ociConfigHandler) deleteTenantUserApiKeys(c *gin.Context) {
// @Summary ---- 域通知收件人与密码策略 ---- // @Summary ---- 域通知收件人与密码策略 ----
// @Tags 租户 IAM // @Tags 租户 IAM
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any // @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @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) {
@@ -352,7 +378,7 @@ func (h *ociConfigHandler) getNotificationRecipients(c *gin.Context) {
if !ok { if !ok {
return return
} }
recipients, err := h.svc.NotificationRecipients(c.Request.Context(), id) recipients, err := h.svc.NotificationRecipients(c.Request.Context(), id, c.Query("domainId"))
if err != nil { if err != nil {
respondError(c, err) respondError(c, err)
return return
@@ -363,8 +389,9 @@ func (h *ociConfigHandler) getNotificationRecipients(c *gin.Context) {
// @Summary 更新通知收件人 // @Summary 更新通知收件人
// @Tags 租户 IAM // @Tags 租户 IAM
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @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) {
@@ -379,7 +406,7 @@ func (h *ociConfigHandler) updateNotificationRecipients(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
recipients, err := h.svc.UpdateNotificationRecipients(c.Request.Context(), id, req.Recipients) recipients, err := h.svc.UpdateNotificationRecipients(c.Request.Context(), id, c.Query("domainId"), req.Recipients)
if err != nil { if err != nil {
respondError(c, err) respondError(c, err)
return return
@@ -390,7 +417,8 @@ func (h *ociConfigHandler) updateNotificationRecipients(c *gin.Context) {
// @Summary 密码策略列表 // @Summary 密码策略列表
// @Tags 租户 IAM // @Tags 租户 IAM
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any // @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @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) {
@@ -398,7 +426,7 @@ func (h *ociConfigHandler) listPasswordPolicies(c *gin.Context) {
if !ok { if !ok {
return return
} }
policies, err := h.svc.PasswordPolicies(c.Request.Context(), id) policies, err := h.svc.PasswordPolicies(c.Request.Context(), id, c.Query("domainId"))
if err != nil { if err != nil {
respondError(c, err) respondError(c, err)
return return
@@ -409,9 +437,10 @@ func (h *ociConfigHandler) listPasswordPolicies(c *gin.Context) {
// @Summary 更新密码策略 // @Summary 更新密码策略
// @Tags 租户 IAM // @Tags 租户 IAM
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @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) {
@@ -428,7 +457,7 @@ func (h *ociConfigHandler) updatePasswordPolicy(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
policy, err := h.svc.UpdatePasswordPolicy(c.Request.Context(), id, c.Param("policyId"), oci.UpdatePasswordPolicyInput{ policy, err := h.svc.UpdatePasswordPolicy(c.Request.Context(), id, c.Query("domainId"), c.Param("policyId"), oci.UpdatePasswordPolicyInput{
PasswordExpiresAfter: req.PasswordExpiresAfter, PasswordExpiresAfter: req.PasswordExpiresAfter,
MinLength: req.MinLength, MinLength: req.MinLength,
NumPasswordsInHistory: req.NumPasswordsInHistory, NumPasswordsInHistory: req.NumPasswordsInHistory,
@@ -443,7 +472,8 @@ func (h *ociConfigHandler) updatePasswordPolicy(c *gin.Context) {
// @Summary 身份域设置 // @Summary 身份域设置
// @Tags 租户 IAM // @Tags 租户 IAM
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @Success 200 {object} map[string]any // @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @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) {
@@ -451,7 +481,7 @@ func (h *ociConfigHandler) getIdentitySetting(c *gin.Context) {
if !ok { if !ok {
return return
} }
setting, err := h.svc.IdentitySetting(c.Request.Context(), id) setting, err := h.svc.IdentitySetting(c.Request.Context(), id, c.Query("domainId"))
if err != nil { if err != nil {
respondError(c, err) respondError(c, err)
return return
@@ -462,8 +492,9 @@ func (h *ociConfigHandler) getIdentitySetting(c *gin.Context) {
// @Summary 更新身份域设置 // @Summary 更新身份域设置
// @Tags 租户 IAM // @Tags 租户 IAM
// @Param id path int true "配置 ID" // @Param id path int true "配置 ID"
// @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) {
@@ -478,7 +509,7 @@ func (h *ociConfigHandler) updateIdentitySetting(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
setting, err := h.svc.UpdateIdentitySetting(c.Request.Context(), id, *req.PrimaryEmailRequired) setting, err := h.svc.UpdateIdentitySetting(c.Request.Context(), id, c.Query("domainId"), *req.PrimaryEmailRequired)
if err != nil { if err != nil {
respondError(c, err) respondError(c, err)
return return
+2 -2
View File
@@ -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) {
@@ -79,7 +79,7 @@ var wsUpgrader = websocket.Upgrader{
// @Success 101 "升级为 WebSocket" // @Success 101 "升级为 WebSocket"
// @Router /api/v1/console-sessions/{sessionId}/ws [get] // @Router /api/v1/console-sessions/{sessionId}/ws [get]
func (h *consoleHandler) ws(c *gin.Context) { func (h *consoleHandler) ws(c *gin.Context) {
if _, err := h.auth.ParseToken(c.Query("token")); err != nil { if _, err := h.auth.ParseToken(c.Request.Context(), c.Query("token")); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
return return
} }
+1 -1
View File
@@ -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) {
+17 -2
View File
@@ -2,6 +2,9 @@ package database
import ( import (
"fmt" "fmt"
"log"
"os"
"time"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
@@ -12,6 +15,18 @@ import (
"oci-portal/internal/model" "oci-portal/internal/model"
) )
// newDBLogger 构造脱敏的 GORM 日志器(S-08):SQL 一律参数化输出,
// 实参(密码哈希 / token / 任务 payload)不落日志;业务正常路径的
// record-not-found 不打 SQL。
func newDBLogger() logger.Interface {
return logger.New(log.New(os.Stdout, "\r\n", log.LstdFlags), logger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: logger.Warn,
IgnoreRecordNotFoundError: true,
ParameterizedQueries: true,
})
}
// Open 按驱动打开数据库并自动迁移全部模型。 // Open 按驱动打开数据库并自动迁移全部模型。
// driver 取值 sqlite(默认)/mysql/postgres;sqlite 用 path,其余用 dsn。 // driver 取值 sqlite(默认)/mysql/postgres;sqlite 用 path,其余用 dsn。
func Open(driver, dsn, path string) (*gorm.DB, error) { func Open(driver, dsn, path string) (*gorm.DB, error) {
@@ -20,7 +35,7 @@ func Open(driver, dsn, path string) (*gorm.DB, error) {
return nil, err return nil, err
} }
db, err := gorm.Open(dialector, &gorm.Config{ db, err := gorm.Open(dialector, &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn), Logger: newDBLogger(),
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("open %s database: %w", dialector.Name(), err) return nil, fmt.Errorf("open %s database: %w", dialector.Name(), err)
@@ -53,7 +68,7 @@ func autoMigrate(db *gorm.DB) error {
&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.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{},
) )
} }
+20 -2
View File
@@ -10,10 +10,11 @@ const (
AccountTypeUnknown = "unknown" AccountTypeUnknown = "unknown"
) )
// 测活状态取值。 // 测活状态取值;suspended 来自账户能力接口的云端暂停标记(区别于失联)
const ( const (
AliveStatusAlive = "alive" AliveStatusAlive = "alive"
AliveStatusDead = "dead" AliveStatusDead = "dead"
AliveStatusSuspended = "suspended"
AliveStatusUnknown = "unknown" AliveStatusUnknown = "unknown"
) )
@@ -65,6 +66,9 @@ type User struct {
PasswordHash string `json:"-"` PasswordHash string `json:"-"`
// TOTP 共享密钥 AES-GCM 密文;空串表示未启用两步验证 // TOTP 共享密钥 AES-GCM 密文;空串表示未启用两步验证
TotpSecretEnc string `json:"-"` TotpSecretEnc string `json:"-"`
// TokenVersion 是 JWT 版本号:凭据/TOTP/外部身份/登录策略变更或撤销会话时
// 原子递增,旧版本令牌随即失效(存量令牌无 ver 视为 0,与零值兼容)
TokenVersion uint `json:"-"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }
@@ -237,6 +241,8 @@ type AiKey struct {
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 = 永关(默认,红线),
@@ -262,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"`
// ProbeModel 是用户测试通过后固定的探测验证模型名;探测时置于候选首位
ProbeModel string `gorm:"size:96" json:"probeModel"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` 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"`
@@ -282,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"`
+90
View File
@@ -1,9 +1,16 @@
package oci package oci
import ( import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings" "strings"
"time" "time"
"github.com/oracle/oci-go-sdk/v65/common"
"oci-portal/internal/model" "oci-portal/internal/model"
) )
@@ -19,6 +26,89 @@ type AccountProfile struct {
PromotionExpires *time.Time PromotionExpires *time.Time
} }
// AccountCapabilities 是账户能力接口的关键字段。来源为 Limits 服务
// 20230301 版 `/compartments/{tenancy}/capabilities?accountInfoOnly=true`
// (与控制台同源;oci-go-sdk v65 未收录,自签名裸调)。
// 该接口对暂停/已终止租户仍可访问,借此把「暂停」从「失联」中区分出来。
type AccountCapabilities struct {
Suspended bool `json:"suspended"`
AccountStatus string `json:"accountStatus"`
FreeTierEnabled bool `json:"freeTierEnabled"`
FreeTierOnly bool `json:"freeTierOnly"`
PromotionStatus string `json:"promotionStatus"`
ProgramType string `json:"programType"`
HasSaasSubscription bool `json:"hasSaasSubscription"`
Deletable bool `json:"deletable"`
}
// capabilitiesEnvelope 对应接口原始响应:顶层与 accountInfo 各有一个
// suspended,任一为 true 即视为暂停。
type capabilitiesEnvelope struct {
Capabilities struct {
Suspended bool `json:"suspended"`
AccountInfo struct {
FreeTierEnabled bool `json:"freeTierEnabled"`
FreeTierOnly bool `json:"freeTierOnly"`
PromotionStatus string `json:"promotionStatus"`
Suspended bool `json:"suspended"`
ProgramType string `json:"programType"`
HasSaasSubscription bool `json:"hasSaasSubscription"`
AccountStatus string `json:"accountStatus"`
Deletable bool `json:"deletable"`
} `json:"accountInfo"`
} `json:"capabilities"`
}
// parseAccountCapabilities 把原始响应体压平为关键字段视图。
func parseAccountCapabilities(body []byte) (AccountCapabilities, error) {
var env capabilitiesEnvelope
if err := json.Unmarshal(body, &env); err != nil {
return AccountCapabilities{}, fmt.Errorf("decode account capabilities: %w", err)
}
info := env.Capabilities.AccountInfo
return AccountCapabilities{
Suspended: env.Capabilities.Suspended || info.Suspended,
AccountStatus: info.AccountStatus,
FreeTierEnabled: info.FreeTierEnabled,
FreeTierOnly: info.FreeTierOnly,
PromotionStatus: info.PromotionStatus,
ProgramType: info.ProgramType,
HasSaasSubscription: info.HasSaasSubscription,
Deletable: info.Deletable,
}, nil
}
// GetAccountCapabilities 实现 Client:查询账户能力(暂停/账户状态/免费层)。
// region 应传 home region;请求带 API Key 签名,并沿用租户出站代理。
func (c *RealClient) GetAccountCapabilities(ctx context.Context, cred Credentials, region string) (AccountCapabilities, error) {
url := fmt.Sprintf("https://limits.%s.oci.oraclecloud.com/20230301/compartments/%s/capabilities?accountInfoOnly=true",
normalizeRegion(region), cred.TenancyOCID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return AccountCapabilities{}, fmt.Errorf("new capabilities request: %w", err)
}
if err := common.DefaultRequestSigner(provider(cred)).Sign(req); err != nil {
return AccountCapabilities{}, fmt.Errorf("sign capabilities request: %w", err)
}
hc := proxyHTTPClient(cred.Proxy)
if hc == nil {
hc = &http.Client{Timeout: proxyClientTimeout}
}
resp, err := hc.Do(req)
if err != nil {
return AccountCapabilities{}, fmt.Errorf("get account capabilities: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return AccountCapabilities{}, fmt.Errorf("read account capabilities: %w", err)
}
if resp.StatusCode != http.StatusOK {
return AccountCapabilities{}, fmt.Errorf("get account capabilities: status %d", resp.StatusCode)
}
return parseAccountCapabilities(body)
}
// ClassifyAccount 按订阅字段判定账户类别: // ClassifyAccount 按订阅字段判定账户类别:
// - paymentModel 非空且不是 FREE_TRIAL:付费; // - paymentModel 非空且不是 FREE_TRIAL:付费;
// - paymentModel 为 FREE_TRIAL 时,promotion ACTIVE 或 tier FREE_AND_TRIAL // - paymentModel 为 FREE_TRIAL 时,promotion ACTIVE 或 tier FREE_AND_TRIAL
+50
View File
@@ -36,3 +36,53 @@ func TestClassifyAccount(t *testing.T) {
}) })
} }
} }
// TestParseAccountCapabilities 用控制台同源接口的真实响应样例驱动。
func TestParseAccountCapabilities(t *testing.T) {
cases := []struct {
name string
body string
wantSusp bool
wantStatus string
wantErr bool
}{
{
name: "已终止租户(顶层与 accountInfo 双 suspended)",
body: `{"compartmentId":"ocid1.tenancy.oc1..x","capabilities":{"suspended":true,` +
`"accountInfo":{"freeTierEnabled":false,"freeTierOnly":false,"promotionStatus":"none",` +
`"suspended":true,"programType":"default","limitsProvisioned":true,"intentToPay":false,` +
`"hasSaasSubscription":true,"accountStatus":"terminated","accountFlags":0,"deletable":true,` +
`"limitIncrease":false,"orgProperties":"0"}}}`,
wantSusp: true,
wantStatus: "terminated",
},
{
name: "正常租户",
body: `{"capabilities":{"suspended":false,"accountInfo":{"accountStatus":"active","freeTierEnabled":true}}}`,
wantSusp: false, wantStatus: "active",
},
{
name: "仅 accountInfo 标记 suspended 也算暂停",
body: `{"capabilities":{"suspended":false,"accountInfo":{"suspended":true}}}`,
wantSusp: true,
},
{name: "非 JSON 报错", body: `<html>`, wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := parseAccountCapabilities([]byte(tc.body))
if tc.wantErr {
if err == nil {
t.Fatal("expected error")
}
return
}
if err != nil {
t.Fatalf("parse: %v", err)
}
if got.Suspended != tc.wantSusp || got.AccountStatus != tc.wantStatus {
t.Fatalf("got %+v, want suspended=%v status=%q", got, tc.wantSusp, tc.wantStatus)
}
})
}
}
+113 -1
View File
@@ -93,6 +93,11 @@ func (c *RealClient) ListAuditEvents(ctx context.Context, cred Credentials, regi
// appendAuditEvents 过滤噪声后追加一页事件;原始事件只对保留条目序列化。 // appendAuditEvents 过滤噪声后追加一页事件;原始事件只对保留条目序列化。
func appendAuditEvents(result *AuditEventsResult, items []audit.AuditEvent) { 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 { for _, ev := range items {
out := toAuditEvent(ev) out := toAuditEvent(ev)
if !keepAuditEvent(out) { if !keepAuditEvent(out) {
@@ -101,8 +106,115 @@ func appendAuditEvents(result *AuditEventsResult, items []audit.AuditEvent) {
if raw, mErr := json.Marshal(ev); mErr == nil { if raw, mErr := json.Marshal(ev); mErr == nil {
out.Raw = raw out.Raw = raw
} }
result.Items = append(result.Items, out) 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{
CompartmentId: &tenancyOCID,
StartTime: &common.SDKTime{Time: cur.Start.UTC().Truncate(time.Minute)},
EndTime: &common.SDKTime{Time: cur.End.UTC().Truncate(time.Minute)},
}
if cur.Page != "" {
req.Page = &cur.Page
}
resp, err := ac.ListEvents(ctx, req)
if err != nil {
return nil, "", fmt.Errorf("list audit events: %w", err)
}
return resp.Items, deref(resp.OpcNextPage), nil
} }
// auditInternalCIDRs 是 OCI 服务内部互调的发起方网段(RFC1918 + CGNAT)。 // auditInternalCIDRs 是 OCI 服务内部互调的发起方网段(RFC1918 + CGNAT)。
+45
View File
@@ -142,3 +142,48 @@ func TestKeepAuditEvent(t *testing.T) {
}) })
} }
} }
func TestAuditCursorAdvance(t *testing.T) {
now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
base := AuditCursor{
Start: now.Add(-24 * time.Hour),
End: now,
WindowHours: 24,
}
cases := []struct {
name string
cur AuditCursor
empty bool
wantHours int
wantDone bool
}{
{"有事件重置 24h 窗", AuditCursor{Start: base.Start, End: base.End, WindowHours: 96}, false, 24, false},
{"空窗倍增", base, true, 48, false},
{"倍增封顶 720h", AuditCursor{Start: base.Start, End: base.End, WindowHours: 512}, true, 720, false},
{"窗宽缺省按 24h 起算", AuditCursor{Start: base.Start, End: base.End}, true, 48, false},
{"越过保留期即尽头", AuditCursor{Start: now.AddDate(0, 0, -366), End: now.AddDate(0, 0, -365), WindowHours: 24}, false, 0, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
next, done := tc.cur.advance(now, tc.empty)
if done != tc.wantDone {
t.Fatalf("done = %v, want %v", done, tc.wantDone)
}
if done {
return
}
if next.WindowHours != tc.wantHours {
t.Fatalf("WindowHours = %d, want %d", next.WindowHours, tc.wantHours)
}
if !next.End.Equal(tc.cur.Start) {
t.Fatalf("新窗 End = %v, 应紧邻上窗 Start %v", next.End, tc.cur.Start)
}
if got := next.End.Sub(next.Start); got != time.Duration(tc.wantHours)*time.Hour {
t.Fatalf("窗宽 = %v, want %dh", got, tc.wantHours)
}
if next.Page != "" {
t.Fatalf("新窗应清空窗内游标, got %q", next.Page)
}
})
}
}
+16 -1
View File
@@ -39,10 +39,18 @@ func ckey(cred Credentials, parts ...string) string {
// bust 写操作成功后失效该租户全部读缓存。 // bust 写操作成功后失效该租户全部读缓存。
func (c *CachedClient) bust(err error, cred Credentials) { func (c *CachedClient) bust(err error, cred Credentials) {
if err == nil { if err == nil {
c.store.DeletePrefix(cred.TenancyOCID + "|") c.InvalidateTenancy(cred.TenancyOCID)
} }
} }
// InvalidateTenancy 主动失效指定 tenancy 的全部 OCI 读缓存。
func (c *CachedClient) InvalidateTenancy(tenancyOCID string) {
if tenancyOCID == "" {
return
}
c.store.DeletePrefix(tenancyOCID + "|")
}
// cachedList 缓存切片结果;命中返回浅拷贝,防调用方排序共享底层数组。 // cachedList 缓存切片结果;命中返回浅拷贝,防调用方排序共享底层数组。
func cachedList[T any](c *CachedClient, key string, ttl time.Duration, fn func() ([]T, error)) ([]T, error) { func cachedList[T any](c *CachedClient, key string, ttl time.Duration, fn func() ([]T, error)) ([]T, error) {
v, err := cache.Do(c.store, key, ttl, fn) v, err := cache.Do(c.store, key, ttl, fn)
@@ -120,6 +128,13 @@ func (c *CachedClient) ListVolumeAttachments(ctx context.Context, cred Credentia
}) })
} }
// ListIdentityDomains 域列表准静态且是所有按域操作的 URL 解析源,长 TTL 缓存。
func (c *CachedClient) ListIdentityDomains(ctx context.Context, cred Credentials, region string) ([]IdentityDomain, error) {
return cachedList(c, ckey(cred, "iddomains", region), cacheTTLStatic, func() ([]IdentityDomain, error) {
return c.Client.ListIdentityDomains(ctx, cred, region)
})
}
// ---- 写:直通,成功后按租户失效 ---- // ---- 写:直通,成功后按租户失效 ----
func (c *CachedClient) LaunchInstance(ctx context.Context, cred Credentials, in CreateInstanceInput) (Instance, error) { func (c *CachedClient) LaunchInstance(ctx context.Context, cred Credentials, in CreateInstanceInput) (Instance, error) {
+15
View File
@@ -86,3 +86,18 @@ func TestCachedClientReturnsClone(t *testing.T) {
t.Errorf("缓存底层数组被调用方污染: second[0]=%s, want i-1", second[0].ID) t.Errorf("缓存底层数组被调用方污染: second[0]=%s, want i-1", second[0].ID)
} }
} }
func TestCachedClientInvalidateTenancy(t *testing.T) {
inner := &countingClient{}
c := NewCachedClient(inner)
ctx := context.Background()
_, _ = c.ListInstances(ctx, testCred("t1"), "r1")
_, _ = c.ListInstances(ctx, testCred("t2"), "r1")
c.InvalidateTenancy("t1")
_, _ = c.ListInstances(ctx, testCred("t1"), "r1")
_, _ = c.ListInstances(ctx, testCred("t2"), "r1")
if inner.instCalls != 3 {
t.Fatalf("invalidate tenancy calls = %d, want 3", inner.instCalls)
}
}
+42 -24
View File
@@ -6,6 +6,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -99,10 +100,18 @@ 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 服务端工具通路)。
GenAiCompatResponses(ctx context.Context, cred Credentials, region string, body []byte) ([]byte, error)
// GenAiCompatResponsesStream 流式直通 /actions/v1/responses,建立成功返回 SSE body。
GenAiCompatResponsesStream(ctx context.Context, cred Credentials, region string, body []byte) (io.ReadCloser, error)
// GenAiCompatSpeech 直通 OpenAI Audio Speech 请求体到 /openai/v1/audio/speech,返回音频与 Content-Type。
GenAiCompatSpeech(ctx context.Context, cred Credentials, region string, body []byte) ([]byte, string, error)
// GenAiRerank 文档重排,返回按相关度排序的下标与得分。
GenAiRerank(ctx context.Context, cred Credentials, region, modelOcid, query string, documents []string, topN *int) ([]RerankRank, error)
// GenAiApplyGuardrails 对单条文本执行内容审核 / PII / 提示注入检测。
GenAiApplyGuardrails(ctx context.Context, cred Credentials, region, text string) (*GuardrailsOutcome, error)
// 控制台连接:创建(VNC/串口连接串)、列出、删除。 // 控制台连接:创建(VNC/串口连接串)、列出、删除。
CreateConsoleConnection(ctx context.Context, cred Credentials, region, instanceID, sshPublicKey string) (ConsoleConnection, error) CreateConsoleConnection(ctx context.Context, cred Credentials, region, instanceID, sshPublicKey string) (ConsoleConnection, error)
ListConsoleConnections(ctx context.Context, cred Credentials, region, instanceID string) ([]ConsoleConnection, error) ListConsoleConnections(ctx context.Context, cred Credentials, region, instanceID string) ([]ConsoleConnection, error)
@@ -122,32 +131,41 @@ type Client interface {
SummarizeCosts(ctx context.Context, cred Credentials, q CostQuery) ([]CostItem, error) SummarizeCosts(ctx context.Context, cred Credentials, q CostQuery) ([]CostItem, error)
// ListAuditEvents 实时查询租户根 compartment 的审计事件(最多翻 maxAuditPages 页, // ListAuditEvents 实时查询租户根 compartment 的审计事件(最多翻 maxAuditPages 页,
// 到限截断并回传续查游标);page 传上次结果的 NextPage 可从断点续翻。 // 到限截断并回传续查游标);page 传上次结果的 NextPage 可从断点续翻。
// 批式懒加载走 ListAuditEventsBatch,本方法保留给详情小窗反查。
ListAuditEvents(ctx context.Context, cred Credentials, region string, start, end time.Time, page string) (AuditEventsResult, error) ListAuditEvents(ctx context.Context, cred Credentials, region string, start, end time.Time, page string) (AuditEventsResult, error)
// 租户用户管理:经典 IAM 为主,新式租户自动回退 Identity Domains。 // ListAuditEventsBatch 从游标位置向更早方向收集约 limit 条保留事件,
ListTenantUsers(ctx context.Context, cred Credentials) ([]TenantUser, error) // 分窗回溯(空窗倍增),跨批不重不漏;到 365 天保留期尽头置 Exhausted。
GetTenantUserDetail(ctx context.Context, cred Credentials, homeRegion, userID string) (TenantUserDetail, error) ListAuditEventsBatch(ctx context.Context, cred Credentials, region string, cur AuditCursor, limit int) (AuditBatchResult, error)
CreateTenantUser(ctx context.Context, cred Credentials, homeRegion string, in CreateTenantUserInput) (TenantUser, error) // 身份域:租户 ACTIVE 域枚举(多域租户选择器数据源)。
UpdateTenantUser(ctx context.Context, cred Credentials, homeRegion, userID string, in UpdateTenantUserInput) (TenantUser, error) ListIdentityDomains(ctx context.Context, cred Credentials, region string) ([]IdentityDomain, error)
DeleteTenantUser(ctx context.Context, cred Credentials, homeRegion, userID string) error // 账户能力:暂停标记/账户状态/免费层信息(对暂停租户仍可访问,测活据此判「暂停」)。
ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, userID string) (string, error) GetAccountCapabilities(ctx context.Context, cred Credentials, region string) (AccountCapabilities, error)
DeleteTenantUserMfaDevices(ctx context.Context, cred Credentials, homeRegion, userID string) (int, error) // 租户用户管理:domainID 非空按域走 Identity Domains SCIM,
// 为空保持经典 IAM 为主、新式租户自动回退的旧行为。
ListTenantUsers(ctx context.Context, cred Credentials, homeRegion, domainID string) ([]TenantUser, error)
GetTenantUserDetail(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (TenantUserDetail, error)
CreateTenantUser(ctx context.Context, cred Credentials, homeRegion, domainID string, in CreateTenantUserInput) (TenantUser, error)
UpdateTenantUser(ctx context.Context, cred Credentials, homeRegion, domainID, userID string, in UpdateTenantUserInput) (TenantUser, error)
DeleteTenantUser(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) error
ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (string, error)
DeleteTenantUserMfaDevices(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (int, error)
DeleteTenantUserApiKeys(ctx context.Context, cred Credentials, homeRegion, userID string, includeCurrent bool) (int, error) DeleteTenantUserApiKeys(ctx context.Context, cred Credentials, homeRegion, userID string, includeCurrent bool) (int, error)
// 域设置:通知收件人、密码策略、身份设置。 // 域设置:通知收件人、密码策略、身份设置。
GetNotificationRecipients(ctx context.Context, cred Credentials, region string) (NotificationRecipients, error) GetNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string) (NotificationRecipients, error)
UpdateNotificationRecipients(ctx context.Context, cred Credentials, region string, emails []string) (NotificationRecipients, error) UpdateNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string, emails []string) (NotificationRecipients, error)
ListPasswordPolicies(ctx context.Context, cred Credentials, region string) ([]PasswordPolicyInfo, error) ListPasswordPolicies(ctx context.Context, cred Credentials, region, domainID string) ([]PasswordPolicyInfo, error)
UpdatePasswordPolicy(ctx context.Context, cred Credentials, region, policyID string, in UpdatePasswordPolicyInput) (PasswordPolicyInfo, error) UpdatePasswordPolicy(ctx context.Context, cred Credentials, region, domainID, policyID string, in UpdatePasswordPolicyInput) (PasswordPolicyInfo, error)
GetIdentitySetting(ctx context.Context, cred Credentials, region string) (IdentitySettingInfo, error) GetIdentitySetting(ctx context.Context, cred Credentials, region, domainID string) (IdentitySettingInfo, error)
UpdateIdentitySetting(ctx context.Context, cred Credentials, region string, primaryEmailRequired bool) (IdentitySettingInfo, error) UpdateIdentitySetting(ctx context.Context, cred Credentials, region, domainID string, primaryEmailRequired bool) (IdentitySettingInfo, error)
// FederationSAML IdP 管理、域 SP 元数据、sign-on 免 MFA 规则。 // FederationSAML IdP 管理、域 SP 元数据、sign-on 免 MFA 规则。
ListIdentityProviders(ctx context.Context, cred Credentials, region string) ([]IdentityProviderInfo, error) ListIdentityProviders(ctx context.Context, cred Credentials, region, domainID string) ([]IdentityProviderInfo, error)
CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region string, in CreateIdpInput) (IdentityProviderInfo, error) CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region, domainID string, in CreateIdpInput) (IdentityProviderInfo, error)
SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, idpID string, enabled bool) (IdentityProviderInfo, error) SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, domainID, idpID string, enabled bool) (IdentityProviderInfo, error)
DeleteIdentityProvider(ctx context.Context, cred Credentials, region, idpID string) error DeleteIdentityProvider(ctx context.Context, cred Credentials, region, domainID, idpID string) error
DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region string) ([]byte, error) DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region, domainID string) ([]byte, error)
ListConsoleSignOnRules(ctx context.Context, cred Credentials, region string) ([]SignOnRuleInfo, error) ListConsoleSignOnRules(ctx context.Context, cred Credentials, region, domainID string) ([]SignOnRuleInfo, error)
CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, idpID, ruleName string) (SignOnRuleInfo, error) CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, idpID, ruleName string) (SignOnRuleInfo, error)
DeleteMfaExemptionRule(ctx context.Context, cred Credentials, region, ruleID string) error DeleteMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, ruleID string) error
// 日志回传链路(方案A):Topic/CUSTOM_HTTPS 订阅/IAM Policy/Service Connector // 日志回传链路(方案A):Topic/CUSTOM_HTTPS 订阅/IAM Policy/Service Connector
// 的幂等创建、状态聚合与销毁;IAM 写操作发往 homeRegion。 // 的幂等创建、状态聚合与销毁;IAM 写操作发往 homeRegion。
EnsureRelayTopic(ctx context.Context, cred Credentials) (RelayResource, error) EnsureRelayTopic(ctx context.Context, cred Credentials) (RelayResource, error)
+88 -25
View File
@@ -47,26 +47,82 @@ type IdentitySettingInfo struct {
PrimaryEmailRequired bool `json:"primaryEmailRequired"` PrimaryEmailRequired bool `json:"primaryEmailRequired"`
} }
// domainURL 解析租户 Default Identity Domain 的 endpoint URL。 // IdentityDomain 是租户下一个 ACTIVE 身份域的列表视图;
func (c *RealClient) domainURL(ctx context.Context, cred Credentials, region string) (string, error) { // URL 仅供后端解析 SCIM 端点,不下发给前端(防面板被引导签名任意地址)。
type IdentityDomain struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
URL string `json:"-"`
HomeRegion string `json:"homeRegion"`
Type string `json:"type"`
LicenseType string `json:"licenseType"`
}
// ListIdentityDomains 实现 Client:列出租户全部 ACTIVE 身份域。
// 无域老租户返回空列表(非错误),调用方据此回退经典 IAM 路径。
func (c *RealClient) ListIdentityDomains(ctx context.Context, cred Credentials, region string) ([]IdentityDomain, error) {
items, err := c.listDomainSummaries(ctx, cred, region)
if err != nil {
return nil, err
}
out := make([]IdentityDomain, 0, len(items))
for _, d := range items {
if d.LifecycleState != identity.DomainLifecycleStateActive {
continue
}
out = append(out, IdentityDomain{
ID: deref(d.Id),
DisplayName: deref(d.DisplayName),
URL: deref(d.Url),
HomeRegion: deref(d.HomeRegion),
Type: string(d.Type),
LicenseType: deref(d.LicenseType),
})
}
return out, nil
}
// listDomainSummaries 拉取租户全部域(数量极少,仍按游标翻全以防万一)。
func (c *RealClient) listDomainSummaries(ctx context.Context, cred Credentials, region string) ([]identity.DomainSummary, error) {
ic, err := c.identityClientAt(cred, region) ic, err := c.identityClientAt(cred, region)
if err != nil {
return nil, err
}
var items []identity.DomainSummary
var page *string
for {
resp, err := ic.ListDomains(ctx, identity.ListDomainsRequest{CompartmentId: &cred.TenancyOCID, Page: page})
if err != nil {
return nil, fmt.Errorf("list identity domains: %w", err)
}
items = append(items, resp.Items...)
if resp.OpcNextPage == nil {
return items, nil
}
page = resp.OpcNextPage
}
}
// domainURL 解析身份域的 endpoint URL:domainID 非空按 OCID 精确匹配
// (URL 只能来自租户自己的域列表,不信任外部输入),为空回退 Default 优先。
func (c *RealClient) domainURL(ctx context.Context, cred Credentials, region, domainID string) (string, error) {
items, err := c.listDomainSummaries(ctx, cred, region)
if err != nil { if err != nil {
return "", err return "", err
} }
resp, err := ic.ListDomains(ctx, identity.ListDomainsRequest{CompartmentId: &cred.TenancyOCID}) url := pickDomainURL(items, domainID)
if err != nil {
return "", fmt.Errorf("list identity domains: %w", err)
}
url := pickDomainURL(resp.Items)
if url == "" { if url == "" {
if domainID != "" {
return "", fmt.Errorf("identity domain %s not found or inactive", domainID)
}
return "", fmt.Errorf("no active identity domain found") return "", fmt.Errorf("no active identity domain found")
} }
return url, nil return url, nil
} }
// domainsClient 解析租户的 Default Identity Domain 并构造 SCIM 客户端。 // domainsClient 解析身份域(domainID 为空取 Default)并构造 SCIM 客户端。
func (c *RealClient) domainsClient(ctx context.Context, cred Credentials, region string) (identitydomains.IdentityDomainsClient, error) { func (c *RealClient) domainsClient(ctx context.Context, cred Credentials, region, domainID string) (identitydomains.IdentityDomainsClient, error) {
url, err := c.domainURL(ctx, cred, region) url, err := c.domainURL(ctx, cred, region, domainID)
if err != nil { if err != nil {
return identitydomains.IdentityDomainsClient{}, err return identitydomains.IdentityDomainsClient{}, err
} }
@@ -80,13 +136,20 @@ func (c *RealClient) domainsClient(ctx context.Context, cred Credentials, region
return dc, nil return dc, nil
} }
// pickDomainURL 优先返回名为 Default 的域,否则取第一个 ACTIVE 的域。 // pickDomainURL 在 ACTIVE 域中选取:domainID 非空按 OCID 匹配;
func pickDomainURL(domains []identity.DomainSummary) string { // 为空优先名为 Default 的域,否则取第一个 ACTIVE 的域。
func pickDomainURL(domains []identity.DomainSummary, domainID string) string {
fallback := "" fallback := ""
for _, d := range domains { for _, d := range domains {
if d.LifecycleState != identity.DomainLifecycleStateActive { if d.LifecycleState != identity.DomainLifecycleStateActive {
continue continue
} }
if domainID != "" {
if deref(d.Id) == domainID {
return deref(d.Url)
}
continue
}
if deref(d.DisplayName) == "Default" { if deref(d.DisplayName) == "Default" {
return deref(d.Url) return deref(d.Url)
} }
@@ -98,8 +161,8 @@ func pickDomainURL(domains []identity.DomainSummary) string {
} }
// GetNotificationRecipients 实现 Client:查询域通知收件人设置。 // GetNotificationRecipients 实现 Client:查询域通知收件人设置。
func (c *RealClient) GetNotificationRecipients(ctx context.Context, cred Credentials, region string) (NotificationRecipients, error) { func (c *RealClient) GetNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string) (NotificationRecipients, error) {
dc, err := c.domainsClient(ctx, cred, region) dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil { if err != nil {
return NotificationRecipients{}, err return NotificationRecipients{}, err
} }
@@ -115,8 +178,8 @@ func (c *RealClient) GetNotificationRecipients(ctx context.Context, cred Credent
// UpdateNotificationRecipients 实现 Client:把域通知改为只发给指定收件人; // UpdateNotificationRecipients 实现 Client:把域通知改为只发给指定收件人;
// emails 为空时关闭 test mode 恢复默认发送。其余字段原样保留。 // emails 为空时关闭 test mode 恢复默认发送。其余字段原样保留。
func (c *RealClient) UpdateNotificationRecipients(ctx context.Context, cred Credentials, region string, emails []string) (NotificationRecipients, error) { func (c *RealClient) UpdateNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string, emails []string) (NotificationRecipients, error) {
dc, err := c.domainsClient(ctx, cred, region) dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil { if err != nil {
return NotificationRecipients{}, err return NotificationRecipients{}, err
} }
@@ -153,8 +216,8 @@ func getNotificationSetting(ctx context.Context, dc identitydomains.IdentityDoma
} }
// ListPasswordPolicies 实现 Client:列出域内全部密码策略。 // ListPasswordPolicies 实现 Client:列出域内全部密码策略。
func (c *RealClient) ListPasswordPolicies(ctx context.Context, cred Credentials, region string) ([]PasswordPolicyInfo, error) { func (c *RealClient) ListPasswordPolicies(ctx context.Context, cred Credentials, region, domainID string) ([]PasswordPolicyInfo, error) {
dc, err := c.domainsClient(ctx, cred, region) dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -171,12 +234,12 @@ func (c *RealClient) ListPasswordPolicies(ctx context.Context, cred Credentials,
// UpdatePasswordPolicy 实现 Client:以 SCIM Patch 修改密码策略的指定字段。 // UpdatePasswordPolicy 实现 Client:以 SCIM Patch 修改密码策略的指定字段。
// 仅 Custom 强度的策略可修改,Simple/Standard 内置策略 OCI 返回 403。 // 仅 Custom 强度的策略可修改,Simple/Standard 内置策略 OCI 返回 403。
func (c *RealClient) UpdatePasswordPolicy(ctx context.Context, cred Credentials, region, policyID string, in UpdatePasswordPolicyInput) (PasswordPolicyInfo, error) { func (c *RealClient) UpdatePasswordPolicy(ctx context.Context, cred Credentials, region, domainID, policyID string, in UpdatePasswordPolicyInput) (PasswordPolicyInfo, error) {
ops := buildPolicyPatchOps(in) ops := buildPolicyPatchOps(in)
if len(ops) == 0 { if len(ops) == 0 {
return PasswordPolicyInfo{}, fmt.Errorf("update password policy: nothing to update") return PasswordPolicyInfo{}, fmt.Errorf("update password policy: nothing to update")
} }
dc, err := c.domainsClient(ctx, cred, region) dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil { if err != nil {
return PasswordPolicyInfo{}, err return PasswordPolicyInfo{}, err
} }
@@ -233,8 +296,8 @@ func removeOp(path string) identitydomains.Operations {
} }
// GetIdentitySetting 实现 Client:读取域身份设置(IdentitySettings 单例资源)。 // GetIdentitySetting 实现 Client:读取域身份设置(IdentitySettings 单例资源)。
func (c *RealClient) GetIdentitySetting(ctx context.Context, cred Credentials, region string) (IdentitySettingInfo, error) { func (c *RealClient) GetIdentitySetting(ctx context.Context, cred Credentials, region, domainID string) (IdentitySettingInfo, error) {
dc, err := c.domainsClient(ctx, cred, region) dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil { if err != nil {
return IdentitySettingInfo{}, err return IdentitySettingInfo{}, err
} }
@@ -254,12 +317,12 @@ func (c *RealClient) GetIdentitySetting(ctx context.Context, cred Credentials, r
} }
// UpdateIdentitySetting 实现 Client:修改「用户需要提供主电子邮件地址」开关。 // UpdateIdentitySetting 实现 Client:修改「用户需要提供主电子邮件地址」开关。
func (c *RealClient) UpdateIdentitySetting(ctx context.Context, cred Credentials, region string, primaryEmailRequired bool) (IdentitySettingInfo, error) { func (c *RealClient) UpdateIdentitySetting(ctx context.Context, cred Credentials, region, domainID string, primaryEmailRequired bool) (IdentitySettingInfo, error) {
current, err := c.GetIdentitySetting(ctx, cred, region) current, err := c.GetIdentitySetting(ctx, cred, region, domainID)
if err != nil { if err != nil {
return IdentitySettingInfo{}, err return IdentitySettingInfo{}, err
} }
dc, err := c.domainsClient(ctx, cred, region) dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil { if err != nil {
return IdentitySettingInfo{}, err return IdentitySettingInfo{}, err
} }
+31
View File
@@ -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)
}
+49
View File
@@ -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)
}
})
}
}
+25 -20
View File
@@ -56,8 +56,8 @@ type CreateIdpInput struct {
} }
// ListIdentityProviders 实现 Client:列出域内 SAML 身份提供者。 // ListIdentityProviders 实现 Client:列出域内 SAML 身份提供者。
func (c *RealClient) ListIdentityProviders(ctx context.Context, cred Credentials, region string) ([]IdentityProviderInfo, error) { func (c *RealClient) ListIdentityProviders(ctx context.Context, cred Credentials, region, domainID string) ([]IdentityProviderInfo, error) {
dc, err := c.domainsClient(ctx, cred, region) dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -76,8 +76,8 @@ func (c *RealClient) ListIdentityProviders(ctx context.Context, cred Credentials
} }
// CreateSamlIdentityProvider 实现 Client:按输入创建禁用态 SAML IdP 并配置 JIT。 // CreateSamlIdentityProvider 实现 Client:按输入创建禁用态 SAML IdP 并配置 JIT。
func (c *RealClient) CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region string, in CreateIdpInput) (IdentityProviderInfo, error) { func (c *RealClient) CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region, domainID string, in CreateIdpInput) (IdentityProviderInfo, error) {
dc, err := c.domainsClient(ctx, cred, region) dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil { if err != nil {
return IdentityProviderInfo{}, err return IdentityProviderInfo{}, err
} }
@@ -139,22 +139,27 @@ func buildSamlIdp(in CreateIdpInput, adminGroup *identitydomains.IdentityProvide
return idp return idp
} }
// adminGroupRef 查询域内 Administrators 组作为 JIT 静态分配目标;不存在时返回 nil。 // adminGroupRef 查询域内管理员组作为 JIT 静态分配与用户授权目标;
// 按 adminGroupNames 优先序取第一个存在的组,都不存在返回 nil。
func adminGroupRef(ctx context.Context, dc identitydomains.IdentityDomainsClient) (*identitydomains.IdentityProviderJitUserProvAssignedGroups, error) { func adminGroupRef(ctx context.Context, dc identitydomains.IdentityDomainsClient) (*identitydomains.IdentityProviderJitUserProvAssignedGroups, error) {
filter := fmt.Sprintf("displayName eq %q", administratorsGroupName) filter := fmt.Sprintf("displayName eq %q or displayName eq %q", adminGroupNames[0], adminGroupNames[1])
count := 1 count := len(adminGroupNames)
resp, err := dc.ListGroups(ctx, identitydomains.ListGroupsRequest{ resp, err := dc.ListGroups(ctx, identitydomains.ListGroupsRequest{
Filter: &filter, Filter: &filter,
Attributes: common.String("id,displayName"), Attributes: common.String("id,displayName"),
Count: &count, Count: &count,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("find Administrators group: %w", err) return nil, fmt.Errorf("find administrators group: %w", err)
}
for _, name := range adminGroupNames {
for _, g := range resp.Resources {
if deref(g.DisplayName) == name && g.Id != nil {
return &identitydomains.IdentityProviderJitUserProvAssignedGroups{Value: g.Id}, nil
}
}
} }
if len(resp.Resources) == 0 || resp.Resources[0].Id == nil {
return nil, nil return nil, nil
}
return &identitydomains.IdentityProviderJitUserProvAssignedGroups{Value: resp.Resources[0].Id}, nil
} }
// jitMappings 生成 JIT 属性映射:来源为 NameID(或指定断言属性), // jitMappings 生成 JIT 属性映射:来源为 NameID(或指定断言属性),
@@ -207,8 +212,8 @@ func ensureJitAttributeMappings(ctx context.Context, dc identitydomains.Identity
// SetIdentityProviderEnabled 实现 Client:启用/停用 IdP 并同步登录页显示。 // SetIdentityProviderEnabled 实现 Client:启用/停用 IdP 并同步登录页显示。
// 顺序有讲究:启用后才能上登录页,停用前必须先从登录页移除。 // 顺序有讲究:启用后才能上登录页,停用前必须先从登录页移除。
func (c *RealClient) SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, idpID string, enabled bool) (IdentityProviderInfo, error) { func (c *RealClient) SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, domainID, idpID string, enabled bool) (IdentityProviderInfo, error) {
dc, err := c.domainsClient(ctx, cred, region) dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil { if err != nil {
return IdentityProviderInfo{}, err return IdentityProviderInfo{}, err
} }
@@ -249,8 +254,8 @@ func patchIdpEnabled(ctx context.Context, dc identitydomains.IdentityDomainsClie
} }
// DeleteIdentityProvider 实现 Client:删除 IdP;先移出登录页并停用。 // DeleteIdentityProvider 实现 Client:删除 IdP;先移出登录页并停用。
func (c *RealClient) DeleteIdentityProvider(ctx context.Context, cred Credentials, region, idpID string) error { func (c *RealClient) DeleteIdentityProvider(ctx context.Context, cred Credentials, region, domainID, idpID string) error {
dc, err := c.domainsClient(ctx, cred, region) dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil { if err != nil {
return err return err
} }
@@ -346,8 +351,8 @@ func toggleJSONList(list, item string, add bool) (string, bool, error) {
// DownloadDomainSamlMetadata 实现 Client:下载域的 SP SAML 元数据 XML。 // DownloadDomainSamlMetadata 实现 Client:下载域的 SP SAML 元数据 XML。
// 域设置「访问签名证书」未开启时端点不可用,自动开启后重试 // 域设置「访问签名证书」未开启时端点不可用,自动开启后重试
// (联邦配置要求 IdP 侧能读取该元数据,开启公开访问是官方标准做法)。 // (联邦配置要求 IdP 侧能读取该元数据,开启公开访问是官方标准做法)。
func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region string) ([]byte, error) { func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region, domainID string) ([]byte, error) {
url, err := c.domainURL(ctx, cred, region) url, err := c.domainURL(ctx, cred, region, domainID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -358,7 +363,7 @@ func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Creden
if status == http.StatusOK { if status == http.StatusOK {
return body, nil return body, nil
} }
if err := c.enableSigningCertPublicAccess(ctx, cred, region); err != nil { if err := c.enableSigningCertPublicAccess(ctx, cred, region, domainID); err != nil {
return nil, err return nil, err
} }
body, status, err = fetchSamlMetadata(ctx, url) body, status, err = fetchSamlMetadata(ctx, url)
@@ -390,8 +395,8 @@ func fetchSamlMetadata(ctx context.Context, url string) ([]byte, int, error) {
} }
// enableSigningCertPublicAccess 开启域设置「访问签名证书」公开访问。 // enableSigningCertPublicAccess 开启域设置「访问签名证书」公开访问。
func (c *RealClient) enableSigningCertPublicAccess(ctx context.Context, cred Credentials, region string) error { func (c *RealClient) enableSigningCertPublicAccess(ctx context.Context, cred Credentials, region, domainID string) error {
dc, err := c.domainsClient(ctx, cred, region) dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil { if err != nil {
return err return err
} }
+62 -129
View File
@@ -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"
@@ -20,7 +18,6 @@ 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"`
@@ -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,16 @@ 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) if _, err = c.GenAiCompatResponses(ctx, cred, region, body); err == nil {
if err == nil {
return http.StatusOK, nil return http.StatusOK, nil
} }
if status, ok := ServiceStatus(err); ok { if status, ok := ServiceStatus(err); ok {
@@ -255,6 +167,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)
-266
View File
@@ -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), &params) != nil || params == nil {
params = map[string]any{}
}
call := generativeaiinference.CohereToolCall{Name: &name, Parameters: &params}
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: &params}
}
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},
}
}
-367
View File
@@ -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, &params) == nil {
fd.Parameters = &params
}
}
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)
}
})
}
}
+127
View File
@@ -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
}
+73
View File
@@ -0,0 +1,73 @@
package oci
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"github.com/oracle/oci-go-sdk/v65/common"
)
// compatResponsesLimit 限制直通响应体大小;web_search 输出含多段引用,给足余量。
const compatResponsesLimit = int64(8 << 20)
// GenAiCompatResponses 实现 Client:把 OpenAI Responses 请求体直通到 OCI
// `/20231130/actions/v1/responses`(IAM 签名)。xAI 服务端工具(web_search /
// x_search / code_interpreter)与 mcp 已被 Oracle 文档正式支持,工具参数与限制
// 遵循 xAI 规格;调用方须自行校验并改写请求体(store/stream)。
func (c *RealClient) GenAiCompatResponses(ctx context.Context, cred Credentials, region string, body []byte) ([]byte, error) {
ic, err := c.genAiInferenceClient(cred, region)
if err != nil {
return nil, err
}
client := ic.BaseClient
common.UpdateEndpointTemplateForOptions(&client)
common.SetMissingTemplateParams(&client)
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)
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
}
// GenAiCompatResponsesStream 实现 Client:以流式直通 OCI `/actions/v1/responses`,
// 建立成功(2xx)返回 SSE body(调用方负责 Close);建立失败返回 SDK ServiceError,
// 与既有渠道切换/熔断错误分类兼容。请求体须由调用方置 stream:true。
func (c *RealClient) GenAiCompatResponsesStream(ctx context.Context, cred Credentials, region string, body []byte) (io.ReadCloser, error) {
ic, err := c.genAiInferenceClient(cred, region)
if err != nil {
return nil, err
}
client := ic.BaseClient
common.UpdateEndpointTemplateForOptions(&client)
common.SetMissingTemplateParams(&client)
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "/actions/v1/responses", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build compat responses stream request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("CompartmentId", cred.TenancyOCID)
request.Header.Set("opc-compartment-id", cred.TenancyOCID)
response, err := client.Call(ctx, request)
if err != nil {
if response != nil && response.Body != nil {
response.Body.Close()
}
return nil, err
}
return response.Body, nil
}
+30
View File
@@ -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)
}
})
}
}
+44
View File
@@ -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
View File
@@ -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)
} }
} }
+107
View File
@@ -0,0 +1,107 @@
package oci
import (
"testing"
"time"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/identity"
"github.com/oracle/oci-go-sdk/v65/identitydomains"
)
func domainSummary(id, name, url string, state identity.DomainLifecycleStateEnum) identity.DomainSummary {
return identity.DomainSummary{Id: &id, DisplayName: &name, Url: &url, LifecycleState: state}
}
func TestPickDomainURL(t *testing.T) {
domains := []identity.DomainSummary{
domainSummary("ocid1.domain.idcs", "OracleIdentityCloudService", "https://idcs-a.example.com", identity.DomainLifecycleStateActive),
domainSummary("ocid1.domain.default", "Default", "https://idcs-b.example.com", identity.DomainLifecycleStateActive),
domainSummary("ocid1.domain.off", "Off", "https://idcs-c.example.com", identity.DomainLifecycleStateInactive),
}
cases := []struct {
name string
domains []identity.DomainSummary
domainID string
want string
}{
{"缺省优先 Default 域", domains, "", "https://idcs-b.example.com"},
{"按 OCID 精确匹配", domains, "ocid1.domain.idcs", "https://idcs-a.example.com"},
{"OCID 不存在返回空", domains, "ocid1.domain.miss", ""},
{"非 ACTIVE 域不可选", domains, "ocid1.domain.off", ""},
{"无 Default 时取第一个 ACTIVE", domains[:1], "", "https://idcs-a.example.com"},
{"空列表返回空", nil, "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := pickDomainURL(tc.domains, tc.domainID); got != tc.want {
t.Fatalf("pickDomainURL() = %q, want %q", got, tc.want)
}
})
}
}
func TestToDomainListUser(t *testing.T) {
created := "2024-12-12T14:52:00Z"
lastLogin := "2026-07-01T08:00:00.123Z"
yes, no := true, false
u := identitydomains.User{
Id: common.String("scim-1"),
Ocid: common.String("ocid1.user.oc1..a"),
UserName: common.String("oci"),
Active: &yes,
Meta: &identitydomains.Meta{Created: &created},
Emails: []identitydomains.UserEmails{{
Value: common.String("a@b.c"), Primary: &yes, Verified: &yes,
Type: identitydomains.UserEmailsTypeWork,
}},
UrnIetfParamsScimSchemasOracleIdcsExtensionMfaUser: &identitydomains.ExtensionMfaUser{
MfaStatus: identitydomains.ExtensionMfaUserMfaStatusEnrolled,
},
UrnIetfParamsScimSchemasOracleIdcsExtensionUserStateUser: &identitydomains.ExtensionUserStateUser{
LastSuccessfulLoginDate: &lastLogin,
},
}
got := toDomainListUser(u, "ocid1.user.oc1..a")
if got.ID != "ocid1.user.oc1..a" || !got.IsCurrentUser {
t.Fatalf("ID/IsCurrentUser 映射错误: %+v", got)
}
if !got.MfaActivated || !got.EmailVerified || got.Email != "a@b.c" {
t.Fatalf("MFA/邮箱映射错误: %+v", got)
}
if got.LifecycleState != "ACTIVE" || got.TimeCreated == nil || got.LastLoginTime == nil {
t.Fatalf("状态/时间映射错误: %+v", got)
}
inactive := identitydomains.User{Id: common.String("scim-2"), Ocid: common.String("ocid1.user.oc1..b"), Active: &no}
if s := toDomainListUser(inactive, "x").LifecycleState; s != "INACTIVE" {
t.Fatalf("inactive 用户 LifecycleState = %q, want INACTIVE", s)
}
}
func TestParseScimTime(t *testing.T) {
valid := "2026-07-10T01:02:03Z"
bad := "not-a-time"
empty := ""
cases := []struct {
name string
in *string
want *time.Time
}{
{"合法 RFC3339", &valid, func() *time.Time { t0, _ := time.Parse(time.RFC3339, valid); return &t0 }()},
{"nil 返回 nil", nil, nil},
{"空串返回 nil", &empty, nil},
{"非法格式返回 nil", &bad, nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := parseScimTime(tc.in)
if (got == nil) != (tc.want == nil) {
t.Fatalf("parseScimTime() = %v, want %v", got, tc.want)
}
if got != nil && !got.Equal(*tc.want) {
t.Fatalf("parseScimTime() = %v, want %v", got, tc.want)
}
})
}
}
+19 -4
View File
@@ -400,6 +400,7 @@ func fillInstanceIPs(ctx context.Context, cc core.ComputeClient, vn core.Virtual
wg sync.WaitGroup wg sync.WaitGroup
mu sync.Mutex mu sync.Mutex
sem = make(chan struct{}, 8) sem = make(chan struct{}, 8)
primarySeen = make(map[*Instance]bool)
) )
for _, att := range attResp.Items { for _, att := range attResp.Items {
inst, ok := active[deref(att.InstanceId)] inst, ok := active[deref(att.InstanceId)]
@@ -416,16 +417,30 @@ func fillInstanceIPs(ctx context.Context, cc core.ComputeClient, vn core.Virtual
return return
} }
mu.Lock() mu.Lock()
inst.SubnetID = deref(vnicResp.SubnetId) applyVnicAddrs(inst, vnicResp.Vnic, primarySeen)
inst.PrivateIP = deref(vnicResp.PrivateIp)
inst.PublicIP = deref(vnicResp.PublicIp)
inst.Ipv6Addresses = vnicResp.Ipv6Addresses
mu.Unlock() mu.Unlock()
}(att.VnicId, inst) }(att.VnicId, inst)
} }
wg.Wait() wg.Wait()
} }
// applyVnicAddrs 将 VNIC 地址写入实例。多网卡实例以主网卡为准:
// 主网卡返回前先用先到的网卡兜底,主网卡到达后覆盖并锁定,避免并发
// 完成顺序决定展示结果。调用方需持有保护 inst 与 primarySeen 的锁。
func applyVnicAddrs(inst *Instance, v core.Vnic, primarySeen map[*Instance]bool) {
isPrimary := v.IsPrimary != nil && *v.IsPrimary
if !isPrimary && (primarySeen[inst] || inst.SubnetID != "") {
return
}
inst.SubnetID = deref(v.SubnetId)
inst.PrivateIP = deref(v.PrivateIp)
inst.PublicIP = deref(v.PublicIp)
inst.Ipv6Addresses = v.Ipv6Addresses
if isPrimary {
primarySeen[inst] = true
}
}
func toInstance(inst core.Instance) Instance { func toInstance(inst core.Instance) Instance {
out := Instance{ out := Instance{
ID: deref(inst.Id), ID: deref(inst.Id),
+42
View File
@@ -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
@@ -25,7 +25,7 @@ func TestRealOCIFederation(t *testing.T) {
client := NewClient() client := NewClient()
region := cred.Region region := cred.Region
metadata, err := client.DownloadDomainSamlMetadata(ctx, cred, region) metadata, err := client.DownloadDomainSamlMetadata(ctx, cred, region, "")
if err != nil { if err != nil {
t.Fatalf("DownloadDomainSamlMetadata: %v", err) t.Fatalf("DownloadDomainSamlMetadata: %v", err)
} }
@@ -34,7 +34,7 @@ func TestRealOCIFederation(t *testing.T) {
} }
t.Logf("domain saml metadata: %d bytes", len(metadata)) t.Logf("domain saml metadata: %d bytes", len(metadata))
before, err := client.ListConsoleSignOnRules(ctx, cred, region) before, err := client.ListConsoleSignOnRules(ctx, cred, region, "")
if err != nil { if err != nil {
t.Fatalf("ListConsoleSignOnRules: %v", err) t.Fatalf("ListConsoleSignOnRules: %v", err)
} }
@@ -43,7 +43,7 @@ func TestRealOCIFederation(t *testing.T) {
idp := createTestIdp(ctx, t, client, cred, region) idp := createTestIdp(ctx, t, client, cred, region)
verifyIdpDefaults(ctx, t, client, cred, region, idp.ID) verifyIdpDefaults(ctx, t, client, cred, region, idp.ID)
activated, err := client.SetIdentityProviderEnabled(ctx, cred, region, idp.ID, true) activated, err := client.SetIdentityProviderEnabled(ctx, cred, region, "", idp.ID, true)
if err != nil { if err != nil {
t.Fatalf("activate idp: %v", err) t.Fatalf("activate idp: %v", err)
} }
@@ -54,7 +54,7 @@ func TestRealOCIFederation(t *testing.T) {
exemptionRoundTrip(ctx, t, client, cred, region, idp.ID, len(before)) exemptionRoundTrip(ctx, t, client, cred, region, idp.ID, len(before))
deactivated, err := client.SetIdentityProviderEnabled(ctx, cred, region, idp.ID, false) deactivated, err := client.SetIdentityProviderEnabled(ctx, cred, region, "", idp.ID, false)
if err != nil { if err != nil {
t.Fatalf("deactivate idp: %v", err) t.Fatalf("deactivate idp: %v", err)
} }
@@ -71,7 +71,7 @@ func createTestIdp(ctx context.Context, t *testing.T, client *RealClient, cred C
t.Fatalf("read test-idp.xml: %v", err) t.Fatalf("read test-idp.xml: %v", err)
} }
name := fmt.Sprintf("oci-portal-e2e-idp-%d", time.Now().Unix()) name := fmt.Sprintf("oci-portal-e2e-idp-%d", time.Now().Unix())
idp, err := client.CreateSamlIdentityProvider(ctx, cred, region, CreateIdpInput{ idp, err := client.CreateSamlIdentityProvider(ctx, cred, region, "", CreateIdpInput{
Name: name, Name: name,
Metadata: string(metadata), Metadata: string(metadata),
Description: "oci-portal e2e temporary idp", Description: "oci-portal e2e temporary idp",
@@ -86,7 +86,7 @@ func createTestIdp(ctx context.Context, t *testing.T, client *RealClient, cred C
t.Cleanup(func() { t.Cleanup(func() {
cctx, ccancel := context.WithTimeout(context.Background(), 2*time.Minute) cctx, ccancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer ccancel() defer ccancel()
if err := client.DeleteIdentityProvider(cctx, cred, region, idp.ID); err != nil { if err := client.DeleteIdentityProvider(cctx, cred, region, "", idp.ID); err != nil {
t.Errorf("cleanup: delete idp: %v", err) t.Errorf("cleanup: delete idp: %v", err)
} else { } else {
t.Log("cleanup: idp deleted") t.Log("cleanup: idp deleted")
@@ -98,7 +98,7 @@ func createTestIdp(ctx context.Context, t *testing.T, client *RealClient, cred C
// verifyIdpDefaults 回读 IdP 断言控制台默认值与 JIT 属性映射两条。 // verifyIdpDefaults 回读 IdP 断言控制台默认值与 JIT 属性映射两条。
func verifyIdpDefaults(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, region, idpID string) { func verifyIdpDefaults(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, region, idpID string) {
t.Helper() t.Helper()
dc, err := client.domainsClient(ctx, cred, region) dc, err := client.domainsClient(ctx, cred, region, "")
if err != nil { if err != nil {
t.Fatalf("domainsClient: %v", err) t.Fatalf("domainsClient: %v", err)
} }
@@ -151,7 +151,7 @@ func verifyJitMappings(ctx context.Context, t *testing.T, dc identitydomains.Ide
// assertLoginPage 校验 DefaultIDPRule 的 SamlIDPs 是否包含目标 IdP。 // assertLoginPage 校验 DefaultIDPRule 的 SamlIDPs 是否包含目标 IdP。
func assertLoginPage(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, region, idpID string, want bool) { func assertLoginPage(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, region, idpID string, want bool) {
t.Helper() t.Helper()
dc, err := client.domainsClient(ctx, cred, region) dc, err := client.domainsClient(ctx, cred, region, "")
if err != nil { if err != nil {
t.Fatalf("domainsClient: %v", err) t.Fatalf("domainsClient: %v", err)
} }
@@ -175,7 +175,7 @@ func assertLoginPage(ctx context.Context, t *testing.T, client *RealClient, cred
// exemptionRoundTrip 创建免 MFA 规则断言置顶与顺延,删除后断言完全复位。 // exemptionRoundTrip 创建免 MFA 规则断言置顶与顺延,删除后断言完全复位。
func exemptionRoundTrip(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, region, idpID string, beforeCount int) { func exemptionRoundTrip(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, region, idpID string, beforeCount int) {
t.Helper() t.Helper()
rule, err := client.CreateMfaExemptionRule(ctx, cred, region, idpID, "oci-portal-e2e-skip-mfa") rule, err := client.CreateMfaExemptionRule(ctx, cred, region, "", idpID, "oci-portal-e2e-skip-mfa")
if err != nil { if err != nil {
t.Fatalf("CreateMfaExemptionRule: %v", err) t.Fatalf("CreateMfaExemptionRule: %v", err)
} }
@@ -185,12 +185,12 @@ func exemptionRoundTrip(ctx context.Context, t *testing.T, client *RealClient, c
if deleted { if deleted {
return return
} }
if err := client.DeleteMfaExemptionRule(ctx, cred, region, rule.ID); err != nil { if err := client.DeleteMfaExemptionRule(ctx, cred, region, "", rule.ID); err != nil {
t.Errorf("cleanup: delete exemption rule: %v", err) t.Errorf("cleanup: delete exemption rule: %v", err)
} }
}() }()
after, err := client.ListConsoleSignOnRules(ctx, cred, region) after, err := client.ListConsoleSignOnRules(ctx, cred, region, "")
if err != nil { if err != nil {
t.Fatalf("ListConsoleSignOnRules after create: %v", err) t.Fatalf("ListConsoleSignOnRules after create: %v", err)
} }
@@ -208,11 +208,11 @@ func exemptionRoundTrip(ctx context.Context, t *testing.T, client *RealClient, c
t.Errorf("condition value %q does not reference idp", after[0].ConditionValue) t.Errorf("condition value %q does not reference idp", after[0].ConditionValue)
} }
if err := client.DeleteMfaExemptionRule(ctx, cred, region, rule.ID); err != nil { if err := client.DeleteMfaExemptionRule(ctx, cred, region, "", rule.ID); err != nil {
t.Fatalf("DeleteMfaExemptionRule: %v", err) t.Fatalf("DeleteMfaExemptionRule: %v", err)
} }
deleted = true deleted = true
restored, err := client.ListConsoleSignOnRules(ctx, cred, region) restored, err := client.ListConsoleSignOnRules(ctx, cred, region, "")
if err != nil { if err != nil {
t.Fatalf("ListConsoleSignOnRules after delete: %v", err) t.Fatalf("ListConsoleSignOnRules after delete: %v", err)
} }
+12 -12
View File
@@ -21,7 +21,7 @@ func TestRealOCITenantUsers(t *testing.T) {
client := NewClient() client := NewClient()
homeRegion := cred.Region homeRegion := cred.Region
users, err := client.ListTenantUsers(ctx, cred) users, err := client.ListTenantUsers(ctx, cred, homeRegion, "")
if err != nil { if err != nil {
t.Fatalf("ListTenantUsers: %v", err) t.Fatalf("ListTenantUsers: %v", err)
} }
@@ -40,7 +40,7 @@ func TestRealOCITenantUsers(t *testing.T) {
t.Logf("tenant users: %d", len(users)) t.Logf("tenant users: %d", len(users))
name := fmt.Sprintf("oci-portal-e2e-user-%d", time.Now().Unix()) name := fmt.Sprintf("oci-portal-e2e-user-%d", time.Now().Unix())
user, err := client.CreateTenantUser(ctx, cred, homeRegion, CreateTenantUserInput{ user, err := client.CreateTenantUser(ctx, cred, homeRegion, "", CreateTenantUserInput{
Name: name, Description: "e2e temp user", Email: name + "@example.com", Name: name, Description: "e2e temp user", Email: name + "@example.com",
GivenName: "E2E", FamilyName: "Temp", GivenName: "E2E", FamilyName: "Temp",
}) })
@@ -49,7 +49,7 @@ func TestRealOCITenantUsers(t *testing.T) {
} }
t.Logf("created user %s (%s)", user.Name, user.ID) t.Logf("created user %s (%s)", user.Name, user.ID)
defer func() { defer func() {
if err := client.DeleteTenantUser(ctx, cred, homeRegion, user.ID); err != nil { if err := client.DeleteTenantUser(ctx, cred, homeRegion, "", user.ID); err != nil {
t.Errorf("cleanup: DeleteTenantUser: %v", err) t.Errorf("cleanup: DeleteTenantUser: %v", err)
} else { } else {
t.Log("cleanup: user deleted") t.Log("cleanup: user deleted")
@@ -57,7 +57,7 @@ func TestRealOCITenantUsers(t *testing.T) {
}() }()
newDesc, newEmail, newGiven := "e2e updated", name+"+upd@example.com", "E2E2" newDesc, newEmail, newGiven := "e2e updated", name+"+upd@example.com", "E2E2"
updated, err := client.UpdateTenantUser(ctx, cred, homeRegion, user.ID, UpdateTenantUserInput{ updated, err := client.UpdateTenantUser(ctx, cred, homeRegion, "", user.ID, UpdateTenantUserInput{
Description: &newDesc, Email: &newEmail, GivenName: &newGiven, Description: &newDesc, Email: &newEmail, GivenName: &newGiven,
}) })
if err != nil { if err != nil {
@@ -68,7 +68,7 @@ func TestRealOCITenantUsers(t *testing.T) {
} }
t.Logf("updated user desc=%q email=%q", updated.Description, updated.Email) t.Logf("updated user desc=%q email=%q", updated.Description, updated.Email)
password, err := client.ResetTenantUserPassword(ctx, cred, homeRegion, user.ID) password, err := client.ResetTenantUserPassword(ctx, cred, homeRegion, "", user.ID)
if err != nil { if err != nil {
t.Fatalf("ResetTenantUserPassword: %v", err) t.Fatalf("ResetTenantUserPassword: %v", err)
} }
@@ -77,7 +77,7 @@ func TestRealOCITenantUsers(t *testing.T) {
} }
t.Logf("password reset ok (len=%d)", len(password)) t.Logf("password reset ok (len=%d)", len(password))
mfaDeleted, err := client.DeleteTenantUserMfaDevices(ctx, cred, homeRegion, user.ID) mfaDeleted, err := client.DeleteTenantUserMfaDevices(ctx, cred, homeRegion, "", user.ID)
if err != nil { if err != nil {
t.Fatalf("DeleteTenantUserMfaDevices: %v", err) t.Fatalf("DeleteTenantUserMfaDevices: %v", err)
} }
@@ -101,12 +101,12 @@ func TestRealOCIDomainSettings(t *testing.T) {
client := NewClient() client := NewClient()
region := cred.Region region := cred.Region
original, err := client.GetNotificationRecipients(ctx, cred, region) original, err := client.GetNotificationRecipients(ctx, cred, region, "")
if err != nil { if err != nil {
t.Fatalf("GetNotificationRecipients: %v", err) t.Fatalf("GetNotificationRecipients: %v", err)
} }
t.Logf("original recipients=%v testMode=%v", original.Recipients, original.TestModeEnabled) t.Logf("original recipients=%v testMode=%v", original.Recipients, original.TestModeEnabled)
updated, err := client.UpdateNotificationRecipients(ctx, cred, region, []string{"e2e@example.com"}) updated, err := client.UpdateNotificationRecipients(ctx, cred, region, "", []string{"e2e@example.com"})
if err != nil { if err != nil {
t.Fatalf("UpdateNotificationRecipients: %v", err) t.Fatalf("UpdateNotificationRecipients: %v", err)
} }
@@ -117,13 +117,13 @@ func TestRealOCIDomainSettings(t *testing.T) {
if !original.TestModeEnabled { if !original.TestModeEnabled {
restoreRecipients = nil restoreRecipients = nil
} }
if _, err := client.UpdateNotificationRecipients(ctx, cred, region, restoreRecipients); err != nil { if _, err := client.UpdateNotificationRecipients(ctx, cred, region, "", restoreRecipients); err != nil {
t.Errorf("restore recipients: %v", err) t.Errorf("restore recipients: %v", err)
} else { } else {
t.Log("recipients restored") t.Log("recipients restored")
} }
policies, err := client.ListPasswordPolicies(ctx, cred, region) policies, err := client.ListPasswordPolicies(ctx, cred, region, "")
if err != nil { if err != nil {
t.Fatalf("ListPasswordPolicies: %v", err) t.Fatalf("ListPasswordPolicies: %v", err)
} }
@@ -133,7 +133,7 @@ func TestRealOCIDomainSettings(t *testing.T) {
} }
t.Logf("policies: %d, patch target %s strength=%s expires=%v", len(policies), target.Name, target.PasswordStrength, target.PasswordExpiresAfter) t.Logf("policies: %d, patch target %s strength=%s expires=%v", len(policies), target.Name, target.PasswordStrength, target.PasswordExpiresAfter)
days := 350 days := 350
patched, err := client.UpdatePasswordPolicy(ctx, cred, region, target.ID, UpdatePasswordPolicyInput{PasswordExpiresAfter: &days}) patched, err := client.UpdatePasswordPolicy(ctx, cred, region, "", target.ID, UpdatePasswordPolicyInput{PasswordExpiresAfter: &days})
if err != nil { if err != nil {
t.Fatalf("UpdatePasswordPolicy: %v", err) t.Fatalf("UpdatePasswordPolicy: %v", err)
} }
@@ -144,7 +144,7 @@ func TestRealOCIDomainSettings(t *testing.T) {
if target.PasswordExpiresAfter != nil { if target.PasswordExpiresAfter != nil {
restore = *target.PasswordExpiresAfter restore = *target.PasswordExpiresAfter
} }
if _, err := client.UpdatePasswordPolicy(ctx, cred, region, target.ID, UpdatePasswordPolicyInput{PasswordExpiresAfter: &restore}); err != nil { if _, err := client.UpdatePasswordPolicy(ctx, cred, region, "", target.ID, UpdatePasswordPolicyInput{PasswordExpiresAfter: &restore}); err != nil {
t.Errorf("restore password policy: %v", err) t.Errorf("restore password policy: %v", err)
} else { } else {
t.Logf("policy restored to %d", restore) t.Logf("policy restored to %d", restore)
+6 -6
View File
@@ -29,8 +29,8 @@ type SignOnRuleInfo struct {
} }
// ListConsoleSignOnRules 实现 Client:按优先级列出 OCI Console sign-on 策略的规则。 // ListConsoleSignOnRules 实现 Client:按优先级列出 OCI Console sign-on 策略的规则。
func (c *RealClient) ListConsoleSignOnRules(ctx context.Context, cred Credentials, region string) ([]SignOnRuleInfo, error) { func (c *RealClient) ListConsoleSignOnRules(ctx context.Context, cred Credentials, region, domainID string) ([]SignOnRuleInfo, error) {
dc, err := c.domainsClient(ctx, cred, region) dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -99,8 +99,8 @@ func fillRuleCondition(ctx context.Context, dc identitydomains.IdentityDomainsCl
// CreateMfaExemptionRule 实现 Client:为指定 IdP 创建免 MFA sign-on 规则并置顶。 // CreateMfaExemptionRule 实现 Client:为指定 IdP 创建免 MFA sign-on 规则并置顶。
// 规则语义与控制台一致:subject.authenticatedBy in [idpID] → authenticationFactor=IDP。 // 规则语义与控制台一致:subject.authenticatedBy in [idpID] → authenticationFactor=IDP。
func (c *RealClient) CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, idpID, ruleName string) (SignOnRuleInfo, error) { func (c *RealClient) CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, idpID, ruleName string) (SignOnRuleInfo, error) {
dc, err := c.domainsClient(ctx, cred, region) dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil { if err != nil {
return SignOnRuleInfo{}, err return SignOnRuleInfo{}, err
} }
@@ -239,11 +239,11 @@ func patchPolicyRules(ctx context.Context, dc identitydomains.IdentityDomainsCli
// DeleteMfaExemptionRule 实现 Client:删除免 MFA 规则并连带清理其条件。 // DeleteMfaExemptionRule 实现 Client:删除免 MFA 规则并连带清理其条件。
// 拒绝删除 Oracle 预置规则。 // 拒绝删除 Oracle 预置规则。
func (c *RealClient) DeleteMfaExemptionRule(ctx context.Context, cred Credentials, region, ruleID string) error { func (c *RealClient) DeleteMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, ruleID string) error {
if strings.HasPrefix(ruleID, "OciConsole") { if strings.HasPrefix(ruleID, "OciConsole") {
return fmt.Errorf("delete sign-on rule: %s is a built-in rule and cannot be deleted", ruleID) return fmt.Errorf("delete sign-on rule: %s is a built-in rule and cannot be deleted", ruleID)
} }
dc, err := c.domainsClient(ctx, cred, region) dc, err := c.domainsClient(ctx, cred, region, domainID)
if err != nil { if err != nil {
return err return err
} }
+147 -28
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"math/big" "math/big"
"slices"
"strings" "strings"
"time" "time"
@@ -28,11 +29,18 @@ type TenantUser struct {
LastLoginTime *time.Time `json:"lastLoginTime,omitempty"` LastLoginTime *time.Time `json:"lastLoginTime,omitempty"`
} }
// Oracle 预置的管理员角色与管理员组显示名。 // Oracle 预置的「身份域管理员」应用角色显示名。
const ( const domainAdminRoleName = "Identity Domain Administrator"
domainAdminRoleName = "Identity Domain Administrator"
administratorsGroupName = "Administrators" // adminGroupNames 是租户管理员组候选名(按优先序):原生域为 Administrators,
) // IDCS 迁移域(idcs foundation,如 OracleIdentityCloudService)为 OCI_Administrators;
// 迁移域另有 IDCS_Administrators 属身份域管理范畴,由上面的应用角色概念覆盖,不算在内。
var adminGroupNames = []string{"Administrators", "OCI_Administrators"}
// isAdminGroupName 判断组显示名是否为租户管理员组。
func isAdminGroupName(name string) bool {
return slices.Contains(adminGroupNames, name)
}
// scimAppRolesAttr 是 SCIM 用户 appRoles 扩展属性的完整路径。 // scimAppRolesAttr 是 SCIM 用户 appRoles 扩展属性的完整路径。
const scimAppRolesAttr = "urn:ietf:params:scim:schemas:oracle:idcs:extension:user:User:appRoles" const scimAppRolesAttr = "urn:ietf:params:scim:schemas:oracle:idcs:extension:user:User:appRoles"
@@ -48,8 +56,8 @@ type TenantUserDetail struct {
} }
// GetTenantUserDetail 实现 Client:查用户域档案与管理员状态(编辑表单预填充用)。 // GetTenantUserDetail 实现 Client:查用户域档案与管理员状态(编辑表单预填充用)。
func (c *RealClient) GetTenantUserDetail(ctx context.Context, cred Credentials, homeRegion, userID string) (TenantUserDetail, error) { func (c *RealClient) GetTenantUserDetail(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (TenantUserDetail, error) {
dc, err := c.domainsClient(ctx, cred, homeRegion) dc, err := c.domainsClient(ctx, cred, homeRegion, domainID)
if err != nil { if err != nil {
return TenantUserDetail{}, err return TenantUserDetail{}, err
} }
@@ -68,7 +76,7 @@ func toTenantUserDetail(u identitydomains.User) TenantUserDetail {
d.FamilyName = deref(u.Name.FamilyName) d.FamilyName = deref(u.Name.FamilyName)
} }
for _, g := range u.Groups { for _, g := range u.Groups {
if deref(g.Display) == administratorsGroupName { if isAdminGroupName(deref(g.Display)) {
d.InAdminGroup = true d.InAdminGroup = true
} }
} }
@@ -94,8 +102,13 @@ func (c *RealClient) identityClientAt(cred Credentials, region string) (identity
return ic, nil return ic, nil
} }
// ListTenantUsers 实现 Client:列出租户全部 IAM 用户。 // ListTenantUsers 实现 Client:列出租户 IAM 用户。
func (c *RealClient) ListTenantUsers(ctx context.Context, cred Credentials) ([]TenantUser, error) { // domainID 非空时经 Identity Domains SCIM 只列该域用户;
// 为空时走经典 IAM(跨域拍平视图,兼容无域老租户)。
func (c *RealClient) ListTenantUsers(ctx context.Context, cred Credentials, homeRegion, domainID string) ([]TenantUser, error) {
if domainID != "" {
return c.listDomainUsers(ctx, cred, homeRegion, domainID)
}
ic, err := c.identityClientAt(cred, "") ic, err := c.identityClientAt(cred, "")
if err != nil { if err != nil {
return nil, err return nil, err
@@ -117,6 +130,78 @@ func (c *RealClient) ListTenantUsers(ctx context.Context, cred Credentials) ([]T
} }
} }
// scimListAttrs 是按域列用户时请求的属性集:列表字段 + MFA 与最近登录扩展。
const scimListAttrs = "id,ocid,userName,description,emails,active,meta.created," +
"urn:ietf:params:scim:schemas:oracle:idcs:extension:mfa:User:mfaStatus," +
"urn:ietf:params:scim:schemas:oracle:idcs:extension:userState:User:lastSuccessfulLoginDate"
// maxScimUserPages 限制按域列用户的翻页数(每页 200,上限 5000 人)。
const maxScimUserPages = 25
// listDomainUsers 经 SCIM 按 startIndex 翻页列出域内全部用户。
func (c *RealClient) listDomainUsers(ctx context.Context, cred Credentials, homeRegion, domainID string) ([]TenantUser, error) {
dc, err := c.domainsClient(ctx, cred, homeRegion, domainID)
if err != nil {
return nil, err
}
var users []TenantUser
attrs := scimListAttrs
count := 200
start := 1
for range maxScimUserPages {
req := identitydomains.ListUsersRequest{Attributes: &attrs, Count: &count, StartIndex: &start}
resp, err := dc.ListUsers(ctx, req)
if err != nil {
return nil, fmt.Errorf("list domain users: %w", err)
}
for _, u := range resp.Resources {
users = append(users, toDomainListUser(u, cred.UserOCID))
}
start += len(resp.Resources)
if resp.TotalResults == nil || start > *resp.TotalResults || len(resp.Resources) == 0 {
return orEmpty(users), nil
}
}
return orEmpty(users), nil
}
// toDomainListUser 把 SCIM 用户映射为列表 DTO;ID 用 IAM OCID
// 保持与经典路径同一 id 语义(IsCurrentUser 比对、后续操作路由)。
func toDomainListUser(u identitydomains.User, currentUserOCID string) TenantUser {
out := toDomainTenantUser(u)
out.IsCurrentUser = out.ID == currentUserOCID
if u.Active != nil && !*u.Active {
out.LifecycleState = "INACTIVE"
}
if u.Meta != nil {
out.TimeCreated = parseScimTime(u.Meta.Created)
}
if ext := u.UrnIetfParamsScimSchemasOracleIdcsExtensionMfaUser; ext != nil {
out.MfaActivated = ext.MfaStatus == identitydomains.ExtensionMfaUserMfaStatusEnrolled
}
if ext := u.UrnIetfParamsScimSchemasOracleIdcsExtensionUserStateUser; ext != nil {
out.LastLoginTime = parseScimTime(ext.LastSuccessfulLoginDate)
}
for _, e := range u.Emails {
if e.Primary != nil && *e.Primary {
out.EmailVerified = e.Verified != nil && *e.Verified
}
}
return out
}
// parseScimTime 解析 SCIM 的 RFC3339 时间串;空值或格式异常返回 nil。
func parseScimTime(s *string) *time.Time {
if s == nil || *s == "" {
return nil
}
t, err := time.Parse(time.RFC3339, *s)
if err != nil {
return nil
}
return &t
}
// CreateTenantUserInput 是创建用户的输入;GivenName/FamilyName 走 // CreateTenantUserInput 是创建用户的输入;GivenName/FamilyName 走
// Identity Domains SCIM(经典 IAM 无对应字段)。两个管理员选项同时勾选时 // Identity Domains SCIM(经典 IAM 无对应字段)。两个管理员选项同时勾选时
// 先授身份域管理员角色,再加入 Administrators 组。 // 先授身份域管理员角色,再加入 Administrators 组。
@@ -133,12 +218,13 @@ type CreateTenantUserInput struct {
// CreateTenantUser 实现 Client:创建用户(须发往 home region)。 // CreateTenantUser 实现 Client:创建用户(须发往 home region)。
// 优先 Identity Domains SCIM(支持姓名与管理员授权);SCIM 不可用时回退 // 优先 Identity Domains SCIM(支持姓名与管理员授权);SCIM 不可用时回退
// 经典 IAM API,此时管理员选项无法执行、姓名并入描述。 // 经典 IAM API,此时管理员选项无法执行、姓名并入描述。
func (c *RealClient) CreateTenantUser(ctx context.Context, cred Credentials, homeRegion string, in CreateTenantUserInput) (TenantUser, error) { // domainID 非空表示用户显式选择了目标域,SCIM 失败不再回退(避免建错域)。
user, err := c.createUserViaDomain(ctx, cred, homeRegion, in) func (c *RealClient) CreateTenantUser(ctx context.Context, cred Credentials, homeRegion, domainID string, in CreateTenantUserInput) (TenantUser, error) {
user, err := c.createUserViaDomain(ctx, cred, homeRegion, domainID, in)
if err == nil { if err == nil {
return user, nil return user, nil
} }
if in.GrantDomainAdmin || in.AddToAdminGroup { if domainID != "" || in.GrantDomainAdmin || in.AddToAdminGroup {
return TenantUser{}, fmt.Errorf("create user via identity domain: %w", err) return TenantUser{}, fmt.Errorf("create user via identity domain: %w", err)
} }
return c.createUserClassic(ctx, cred, homeRegion, in) return c.createUserClassic(ctx, cred, homeRegion, in)
@@ -170,8 +256,8 @@ func (c *RealClient) createUserClassic(ctx context.Context, cred Credentials, ho
} }
// createUserViaDomain 通过 Identity Domains SCIM 创建用户并按选项授权。 // createUserViaDomain 通过 Identity Domains SCIM 创建用户并按选项授权。
func (c *RealClient) createUserViaDomain(ctx context.Context, cred Credentials, homeRegion string, in CreateTenantUserInput) (TenantUser, error) { func (c *RealClient) createUserViaDomain(ctx context.Context, cred Credentials, homeRegion, domainID string, in CreateTenantUserInput) (TenantUser, error) {
dc, err := c.domainsClient(ctx, cred, homeRegion) dc, err := c.domainsClient(ctx, cred, homeRegion, domainID)
if err != nil { if err != nil {
return TenantUser{}, err return TenantUser{}, err
} }
@@ -373,9 +459,9 @@ type UpdateTenantUserInput struct {
// UpdateTenantUser 实现 Client:编辑用户资料(须发往 home region)。 // UpdateTenantUser 实现 Client:编辑用户资料(须发往 home region)。
// 优先 Identity Domains SCIM(支持姓名);用户不在域中时回退经典 // 优先 Identity Domains SCIM(支持姓名);用户不在域中时回退经典
// IAM API(仅备注与邮箱,姓名变更报错)。 // IAM API(仅备注与邮箱,姓名变更报错);domainID 非空时不回退
func (c *RealClient) UpdateTenantUser(ctx context.Context, cred Credentials, homeRegion, userID string, in UpdateTenantUserInput) (TenantUser, error) { func (c *RealClient) UpdateTenantUser(ctx context.Context, cred Credentials, homeRegion, domainID, userID string, in UpdateTenantUserInput) (TenantUser, error) {
dc, err := c.domainsClient(ctx, cred, homeRegion) dc, err := c.domainsClient(ctx, cred, homeRegion, domainID)
if err != nil { if err != nil {
return TenantUser{}, err return TenantUser{}, err
} }
@@ -394,6 +480,9 @@ func (c *RealClient) UpdateTenantUser(ctx context.Context, cred Credentials, hom
} }
return user, nil return user, nil
} }
if domainID != "" {
return TenantUser{}, fmt.Errorf("update user %s: user not found in identity domain", userID)
}
if in.GivenName != nil || in.FamilyName != nil { if in.GivenName != nil || in.FamilyName != nil {
return TenantUser{}, fmt.Errorf("update user %s: 姓名仅身份域用户可修改(该用户不在身份域中)", userID) return TenantUser{}, fmt.Errorf("update user %s: 姓名仅身份域用户可修改(该用户不在身份域中)", userID)
} }
@@ -495,7 +584,11 @@ func emailPatchOp(u identitydomains.User, email string) identitydomains.Operatio
} }
// DeleteTenantUser 实现 Client:删除 IAM 用户(须发往 home region)。 // DeleteTenantUser 实现 Client:删除 IAM 用户(须发往 home region)。
func (c *RealClient) DeleteTenantUser(ctx context.Context, cred Credentials, homeRegion, userID string) error { // domainID 非空时经该域 SCIM 删除(经典 API 对非默认域用户不生效)。
func (c *RealClient) DeleteTenantUser(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) error {
if domainID != "" {
return c.deleteDomainUser(ctx, cred, homeRegion, domainID, userID)
}
ic, err := c.identityClientAt(cred, homeRegion) ic, err := c.identityClientAt(cred, homeRegion)
if err != nil { if err != nil {
return err return err
@@ -506,9 +599,31 @@ func (c *RealClient) DeleteTenantUser(ctx context.Context, cred Credentials, hom
return nil return nil
} }
// deleteDomainUser 经 SCIM 强制删除域用户(forceDelete 连带其授权与组成员关系)。
func (c *RealClient) deleteDomainUser(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) error {
dc, err := c.domainsClient(ctx, cred, homeRegion, domainID)
if err != nil {
return err
}
scimID, err := scimUserID(ctx, dc, userID)
if err != nil {
return err
}
force := true
_, err = dc.DeleteUser(ctx, identitydomains.DeleteUserRequest{UserId: &scimID, ForceDelete: &force})
if err != nil {
return fmt.Errorf("delete domain user %s: %w", userID, err)
}
return nil
}
// ResetTenantUserPassword 实现 Client:重置用户控制台密码并返回一次性新密码。 // ResetTenantUserPassword 实现 Client:重置用户控制台密码并返回一次性新密码。
// 优先经典 IAM API;新式租户(用户只存在于 Identity Domain)自动回退 SCIM。 // domainID 非空直走该域 SCIM;为空优先经典 IAM API,
func (c *RealClient) ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, userID string) (string, error) { // 新式租户(用户只存在于 Identity Domain)自动回退 SCIM。
func (c *RealClient) ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (string, error) {
if domainID != "" {
return c.resetPasswordViaDomain(ctx, cred, homeRegion, domainID, userID)
}
ic, err := c.identityClientAt(cred, homeRegion) ic, err := c.identityClientAt(cred, homeRegion)
if err != nil { if err != nil {
return "", err return "", err
@@ -520,12 +635,12 @@ func (c *RealClient) ResetTenantUserPassword(ctx context.Context, cred Credentia
if !isClassicUnsupported(err) { if !isClassicUnsupported(err) {
return "", fmt.Errorf("reset ui password of %s: %w", userID, err) return "", fmt.Errorf("reset ui password of %s: %w", userID, err)
} }
return c.resetPasswordViaDomain(ctx, cred, homeRegion, userID) return c.resetPasswordViaDomain(ctx, cred, homeRegion, "", userID)
} }
// resetPasswordViaDomain 通过 Identity Domains SCIM API 强设随机新密码。 // resetPasswordViaDomain 通过 Identity Domains SCIM API 强设随机新密码。
func (c *RealClient) resetPasswordViaDomain(ctx context.Context, cred Credentials, homeRegion, userID string) (string, error) { func (c *RealClient) resetPasswordViaDomain(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (string, error) {
dc, err := c.domainsClient(ctx, cred, homeRegion) dc, err := c.domainsClient(ctx, cred, homeRegion, domainID)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -558,7 +673,11 @@ func (c *RealClient) resetPasswordViaDomain(ctx context.Context, cred Credential
// DeleteTenantUserMfaDevices 实现 Client:清除用户全部 MFA。 // DeleteTenantUserMfaDevices 实现 Client:清除用户全部 MFA。
// 先删经典 TOTP 设备,再通过 Identity Domains 移除全部认证因子(尽力)。 // 先删经典 TOTP 设备,再通过 Identity Domains 移除全部认证因子(尽力)。
func (c *RealClient) DeleteTenantUserMfaDevices(ctx context.Context, cred Credentials, homeRegion, userID string) (int, error) { func (c *RealClient) DeleteTenantUserMfaDevices(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (int, error) {
// 显式指定域时用户的认证因子只在域内,经典 TOTP 清理不适用
if domainID != "" {
return 0, c.removeDomainFactors(ctx, cred, homeRegion, domainID, userID)
}
ic, err := c.identityClientAt(cred, homeRegion) ic, err := c.identityClientAt(cred, homeRegion)
if err != nil { if err != nil {
return 0, err return 0, err
@@ -567,7 +686,7 @@ func (c *RealClient) DeleteTenantUserMfaDevices(ctx context.Context, cred Creden
if err != nil { if err != nil {
return deleted, err return deleted, err
} }
if err := c.removeDomainFactors(ctx, cred, homeRegion, userID); err != nil { if err := c.removeDomainFactors(ctx, cred, homeRegion, "", userID); err != nil {
return deleted, err return deleted, err
} }
return deleted, nil return deleted, nil
@@ -594,8 +713,8 @@ func deleteClassicTotpDevices(ctx context.Context, ic identity.IdentityClient, u
// removeDomainFactors 通过 SCIM AuthenticationFactorsRemover 移除用户 // removeDomainFactors 通过 SCIM AuthenticationFactorsRemover 移除用户
// 在 Identity Domain 注册的全部认证因子;用户不在域中时视为无事可做。 // 在 Identity Domain 注册的全部认证因子;用户不在域中时视为无事可做。
func (c *RealClient) removeDomainFactors(ctx context.Context, cred Credentials, homeRegion, userID string) error { func (c *RealClient) removeDomainFactors(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) error {
dc, err := c.domainsClient(ctx, cred, homeRegion) dc, err := c.domainsClient(ctx, cred, homeRegion, domainID)
if err != nil { if err != nil {
return err return err
} }
+1 -1
View File
@@ -47,7 +47,7 @@ func TestToTenantUserDetail(t *testing.T) {
}, },
Groups: []identitydomains.UserGroups{ Groups: []identitydomains.UserGroups{
{Value: common.String("g1"), Display: common.String("Readers")}, {Value: common.String("g1"), Display: common.String("Readers")},
{Value: common.String("g2"), Display: common.String(administratorsGroupName)}, {Value: common.String("g2"), Display: common.String(adminGroupNames[0])},
}, },
UrnIetfParamsScimSchemasOracleIdcsExtensionUserUser: adminRoles, UrnIetfParamsScimSchemasOracleIdcsExtensionUserUser: adminRoles,
}, },
-332
View File
@@ -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 }
-136
View File
@@ -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)
}
}
+491 -27
View File
@@ -5,15 +5,19 @@ 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"
"gorm.io/gorm/clause"
"oci-portal/internal/aiwire" "oci-portal/internal/aiwire"
"oci-portal/internal/model" "oci-portal/internal/model"
@@ -56,11 +60,127 @@ 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
} }
// 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))
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"
)
// defaultStreamGuardKB 是保险丝阈值缺省值,低于实测断流边界留余量。
const defaultStreamGuardKB = 60
// loadBoolSetting 读 settings 表布尔键,无行或值非法时返回缺省。
func loadBoolSetting(db *gorm.DB, key string, def bool) bool {
var row model.Setting
if err := db.Where("key = ?", key).First(&row).Error; err != nil {
return def
}
return row.Value == "1"
}
// loadIntSetting 读 settings 表整数键,无行或解析失败时返回缺省。
func loadIntSetting(db *gorm.DB, key string, def int) int {
var row model.Setting
if err := db.Where("key = ?", key).First(&row).Error; err != nil {
return def
}
n, err := strconv.Atoi(row.Value)
if err != nil {
return def
}
return n
}
// saveBoolSetting 持久化布尔键。
func (s *AiGatewayService) saveBoolSetting(ctx context.Context, key string, on bool) error {
value := "0"
if on {
value = "1"
}
return s.db.WithContext(ctx).Save(&model.Setting{Key: key, Value: value}).Error
}
// FilterDeprecated 返回「过滤弃用模型」开关状态。
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
}
// 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 注册渠道数量变化钩子(渠道创建/删除成功后调用)。
@@ -78,7 +198,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("密钥名称不能为空")
@@ -94,13 +214,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[:])
@@ -113,8 +248,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
@@ -125,6 +260,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
} }
@@ -214,11 +357,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 修改渠道名称 / 分组 / 启停 / 优先级 / 权重。
@@ -261,7 +427,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 {
@@ -289,12 +455,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 服务不可用或未开放)"
@@ -305,40 +476,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
} }
return sorted if v := modelVendor(m); !seenVendor[v] {
seenVendor[v] = true
used[i] = true
picked = append(picked, m)
}
}
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 {
@@ -376,7 +605,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 {
@@ -390,6 +619,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
} }
@@ -415,13 +648,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).
@@ -429,6 +730,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{}}
@@ -446,6 +750,40 @@ func (s *AiGatewayService) GatewayModels(ctx context.Context, group string) (aiw
return list, nil return list, nil
} }
// AggregatedModel 是聚合模型目录条目(设置页黑名单添加弹窗用)。
type AggregatedModel struct {
Name string `json:"name"`
Capability string `json:"capability"`
}
// AggregatedModels 返回启用渠道去重后的模型目录(含能力);空能力归一为 CHAT。
// 与模型列表口径一致:「过滤弃用」开启时弃用模型不出现在目录中。
func (s *AiGatewayService) AggregatedModels(ctx context.Context) ([]AggregatedModel, error) {
q := s.db.WithContext(ctx).
Joins("JOIN ai_channels ON ai_channels.id = ai_model_caches.channel_id AND ai_channels.enabled = ?", true)
if s.FilterDeprecated() {
q = q.Where("ai_model_caches.deprecated_at IS NULL")
}
var rows []model.AiModelCache
if err := q.Order("ai_model_caches.name ASC").Find(&rows).Error; err != nil {
return nil, fmt.Errorf("聚合模型目录: %w", err)
}
seen := map[string]bool{}
out := []AggregatedModel{}
for _, r := range rows {
if seen[r.Name] {
continue
}
seen[r.Name] = true
cap := r.Capability
if cap == "" {
cap = "CHAT"
}
out = append(out, AggregatedModel{Name: r.Name, Capability: cap})
}
return out, nil
}
// DeprecatingModels 返回 within 窗口内即将退役或即将弃用的在池模型(按名称去重): // DeprecatingModels 返回 within 窗口内即将退役或即将弃用的在池模型(按名称去重):
// 退役(TimeOnDemandRetired)才导致不可调用,单独标注;已过弃用日但未到退役日的 // 退役(TimeOnDemandRetired)才导致不可调用,单独标注;已过弃用日但未到退役日的
// 模型仍可正常调用,不再反复告警;已过退役日的在同步层剔除,不会出现在池中。 // 模型仍可正常调用,不再反复告警;已过退役日的在同步层剔除,不会出现在池中。
@@ -501,18 +839,134 @@ 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)。
func (s *AiGatewayService) LogCall(entry model.AiCallLog) uint { func (s *AiGatewayService) LogCall(entry model.AiCallLog) uint {
entry.ErrMsg = truncateErr(entry.ErrMsg) entry.ErrMsg = truncateErr(entry.ErrMsg)
if err := s.db.Create(&entry).Error; err != nil { stored := false
err := s.db.Transaction(func(tx *gorm.DB) error {
ok, err := lockAiLogParent(tx, &model.AiChannel{}, entry.ChannelID)
if err != nil || !ok {
return err
}
stored = true
return tx.Create(&entry).Error
})
if err != nil {
log.Printf("ai call log: %v", err) log.Printf("ai call log: %v", err)
return 0 return 0
} }
if !stored {
return 0
}
return entry.ID return entry.ID
} }
func lockAiLogParent(tx *gorm.DB, value any, id uint) (bool, error) {
if id == 0 {
return true, nil
}
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(value, id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return err == nil, err
}
// CallLogs 分页查询调用日志。 // CallLogs 分页查询调用日志。
func (s *AiGatewayService) CallLogs(ctx context.Context, page, size int) ([]model.AiCallLog, int64, error) { func (s *AiGatewayService) CallLogs(ctx context.Context, page, size int) ([]model.AiCallLog, int64, error) {
if page < 1 { if page < 1 {
@@ -555,9 +1009,19 @@ func (s *AiGatewayService) UpdateKeyContentLog(ctx context.Context, id uint, hou
// LogContent 写一条内容日志(调用方已确认密钥开启且未过期);正文截断至 64KB。 // LogContent 写一条内容日志(调用方已确认密钥开启且未过期);正文截断至 64KB。
func (s *AiGatewayService) LogContent(entry model.AiContentLog) { func (s *AiGatewayService) LogContent(entry model.AiContentLog) {
if entry.CallLogID == 0 {
return
}
entry.RequestBody = truncateBody(entry.RequestBody) entry.RequestBody = truncateBody(entry.RequestBody)
entry.ResponseBody = truncateBody(entry.ResponseBody) entry.ResponseBody = truncateBody(entry.ResponseBody)
if err := s.db.Create(&entry).Error; err != nil { err := s.db.Transaction(func(tx *gorm.DB) error {
ok, err := lockAiLogParent(tx, &model.AiCallLog{}, entry.CallLogID)
if err != nil || !ok {
return err
}
return tx.Create(&entry).Error
})
if err != nil {
log.Printf("ai content log: %v", err) log.Printf("ai content log: %v", err)
} }
} }
+40 -21
View File
@@ -3,6 +3,7 @@ package service
import ( import (
"context" "context"
"errors" "errors"
"io"
"math/rand" "math/rand"
"time" "time"
@@ -26,27 +27,31 @@ type aiCandidate struct {
modelOcid string modelOcid string
} }
// Chat 编排非流式调用:选渠道 → 调用 → 可重试错误换渠道(整请求上限 3 次)。 // RespPassthrough 编排一次非流式直通调用:选渠道(priority→加权随机)→ 调用 →
// group 非空时只在同分组渠道内路由(取自调用密钥) // 可重试错误换渠道(整请求上限 3 次)并维护熔断;group 非空时只在同分组渠道内路由。
func (s *AiGatewayService) Chat(ctx context.Context, ir aiwire.ChatRequest, group string) (*aiwire.ChatResponse, ChatMeta, error) { // 上游为 OpenAI-compatible /actions/v1/responses(实测可用,无 Oracle 文档合同)。
func (s *AiGatewayService) RespPassthrough(ctx context.Context, raw []byte, modelName, group string) ([]byte, ChatMeta, error) {
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, "CHAT", excluded)
if err != nil { if err != nil {
return nil, meta, firstErr(lastErr, err) return nil, 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) payload, err := s.passthroughOnce(ctx, cand, raw)
if err == nil { if err == nil {
s.markSuccess(ctx, cand.ch.ID) s.markSuccess(ctx, cand.ch.ID)
return resp, meta, nil return payload, meta, nil
} }
if !retryable(err) { retry, penalize := switchable(err)
if !retry {
return nil, meta, err return nil, 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
@@ -54,22 +59,22 @@ func (s *AiGatewayService) Chat(ctx context.Context, ir aiwire.ChatRequest, grou
return nil, meta, lastErr return nil, meta, lastErr
} }
func (s *AiGatewayService) callOnce(ctx context.Context, cand *aiCandidate, ir aiwire.ChatRequest) (*aiwire.ChatResponse, error) { 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)
} }
// 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{} 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, "CHAT", excluded)
if err != nil { if err != nil {
return nil, meta, firstErr(lastErr, err) return nil, meta, firstErr(lastErr, err)
} }
@@ -78,15 +83,18 @@ func (s *AiGatewayService) OpenStream(ctx context.Context, ir aiwire.ChatRequest
if err != nil { if err != nil {
return nil, meta, err return nil, meta, err
} }
stream, err := s.client.GenAiChatStream(ctx, cred, cand.ch.Region, cand.modelOcid, ir) stream, err := s.client.GenAiCompatResponsesStream(ctx, cred, cand.ch.Region, raw)
if err == nil { if err == nil {
s.markSuccess(ctx, cand.ch.ID) s.markSuccess(ctx, cand.ch.ID)
return stream, meta, nil return stream, meta, nil
} }
if !retryable(err) { retry, penalize := switchable(err)
if !retry {
return nil, meta, err return nil, 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
@@ -102,12 +110,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 +164,9 @@ func (s *AiGatewayService) modelChannels(ctx context.Context, modelName, capabil
} else { } else {
q = q.Where("capability = ?", capability) q = q.Where("capability = ?", capability)
} }
if s.FilterDeprecated() {
q = q.Where("deprecated_at IS NULL")
}
var rows []model.AiModelCache var rows []model.AiModelCache
if err := q.Find(&rows).Error; err != nil { if err := q.Find(&rows).Error; err != nil {
return nil, nil, err return nil, nil, err
@@ -221,10 +237,13 @@ func (s *AiGatewayService) Embeddings(ctx context.Context, req aiwire.Embeddings
s.markSuccess(ctx, cand.ch.ID) s.markSuccess(ctx, cand.ch.ID)
return resp, meta, nil return resp, meta, nil
} }
if !retryable(err) { retry, penalize := switchable(err)
if !retry {
return nil, meta, err return nil, 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
+284
View File
@@ -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
}
+222
View File
@@ -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)
}
}
+29
View File
@@ -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)
}
}
+682 -64
View File
@@ -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
// probedNames 记录试调过的模型名,供断言候选过滤
probedNames []string
chatCalls int chatCalls int
regions []string 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) ([]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) (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.probeCode, f.probeErr
return f.chatResp, nil
} }
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)
} }
} }
@@ -255,7 +319,7 @@ 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)
} }
@@ -488,6 +913,7 @@ func TestAiContentLogSwitch(t *testing.T) {
t.Error("超过 7 天上限应被拒绝") t.Error("超过 7 天上限应被拒绝")
} }
// 写入与截断(带调用日志关联) // 写入与截断(带调用日志关联)
mustCreate(t, gw.db, &model.AiCallLog{ID: 42, KeyID: key.ID, ChannelID: 0})
gw.LogContent(model.AiContentLog{CallLogID: 42, KeyID: key.ID, KeyName: "k1", Endpoint: "openai", Model: "m", RequestBody: strings.Repeat("x", 70*1024)}) gw.LogContent(model.AiContentLog{CallLogID: 42, KeyID: key.ID, KeyName: "k1", Endpoint: "openai", Model: "m", RequestBody: strings.Repeat("x", 70*1024)})
rows, total, err := gw.ContentLogs(ctx, key.ID, 0, 1, 20) rows, total, err := gw.ContentLogs(ctx, key.ID, 0, 1, 20)
if err != nil || total != 1 || len(rows[0].RequestBody) != 64*1024 { if err != nil || total != 1 || len(rows[0].RequestBody) != 64*1024 {
@@ -506,3 +932,195 @@ 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)
}
}
+543 -195
View File
@@ -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
}
// 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)
} }
return []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent(in.Text)}}, nil body["store"] = false
compat := respCompatTools(body)
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{}
} }
var msgs []aiwire.ChatMessage compat := RespCompat{NsRefs: map[string]RespNsRef{}, CustomNames: map[string]struct{}{}}
for _, it := range in.Items { kept := make([]any, 0, len(tools))
out, err := respItemToMessages(it, msgs) 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 { if err != nil {
return nil, err return nil, err
} }
msgs = out if hadStatus {
resp["status"] = status
} else {
delete(resp, "status")
} }
return msgs, nil 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 {
return nil, err
}
events = append(events, ev)
}
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
} }
role := respRole(it.Role) dec := json.NewDecoder(strings.NewReader(string(payload)))
return append(msgs, aiwire.ChatMessage{Role: role, Content: respContentToIR(it.Content)}), nil dec.UseNumber()
case "function_call": var body map[string]any
call := aiwire.ToolCall{ID: it.CallID, Type: "function", if dec.Decode(&body) != nil {
Function: aiwire.FunctionCall{Name: it.Name, Arguments: it.Arguments}} return payload
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 if !respRestoreOutput(body["output"], compat) {
case "function_call_output": return payload
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)
} }
} out, err := json.Marshal(body)
if err != nil {
// respRole 归一 item 角色;developer 视为 system,缺省 user。 return payload
func respRole(role string) string {
switch role {
case "system", "developer":
return "system"
case "assistant":
return "assistant"
default:
return "user"
}
}
// respContentToIR 把 Responses 内容转 IR;纯文本拍平,含图片时保留部件顺序。
func respContentToIR(c aiwire.RespContent) aiwire.Content {
var parts []aiwire.ContentPart
hasImage := false
for _, p := range c.Parts {
switch p.Type {
case "input_image":
parts = append(parts, aiwire.ContentPart{Type: "image_url",
ImageURL: &aiwire.ImageURL{URL: p.ImageURL, Detail: p.Detail}})
hasImage = true
default:
if p.Text != "" {
parts = append(parts, aiwire.ContentPart{Type: "text", Text: p.Text})
}
}
}
if !c.IsArray || !hasImage {
return aiwire.NewTextContent(c.JoinText())
}
return aiwire.NewPartsContent(parts)
}
// respTools 把扁平工具定义还原为嵌套 Chat 形态;strict 无 OCI 对应,忽略。
func respTools(tools []aiwire.RespTool) []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.Parameters}})
} }
return out return out
} }
// respToolChoice 转换 tool_choice:字符串直通;{type:function,name} 转嵌套; // RespRestoreToolCallsEvent 流式单事件还原:处理事件顶层 item(output_item.*)
// allowed_tools 等对象形态降级 auto。 // 与 response.output(created/completed 等快照)。返回还原后数据与是否改动;
func respToolChoice(raw json.RawMessage) json.RawMessage { // 未改动时调用方转发原始行,保持字节级直通。
if len(raw) == 0 { 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
}
}
return changed
}
// respRestoreCallItem 还原单个 function_call 项,返回是否改动。namespace 还原与
// custom 回转互斥:custom 工具不参与 namespace 拍平。
func respRestoreCallItem(v any, compat RespCompat) bool {
item, _ := v.(map[string]any)
if item == nil || item["type"] != "function_call" {
return false
}
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
}
// respUnwrapCustomInput 解包 arguments:形如 {"input": 串} 取其 input(非串取该
// 值原文),其余情况整串返回。与请求侧 respCustomCallArguments 互逆。
func respUnwrapCustomInput(arguments any) string {
s, _ := arguments.(string)
var probe map[string]json.RawMessage
if json.Unmarshal([]byte(s), &probe) == nil {
if raw, ok := probe["input"]; ok {
var str string
if json.Unmarshal(raw, &str) == nil {
return str
}
return string(raw)
}
}
return s
}
// RespPassthroughUsage 从直通响应提取用量;缺失时返回 nil(日志记零)。
func RespPassthroughUsage(payload []byte) *aiwire.Usage {
var root struct {
Usage *aiwire.RespUsage `json:"usage"`
}
if json.Unmarshal(payload, &root) != nil {
return nil return nil
} }
t := strings.TrimSpace(string(raw)) return usageFromResp(root.Usage)
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 形态无需显式指定 // usageFromResp 换算 Responses usage 为 OpenAI 口径(缓存命中透传);nil 原样返回
func respFormat(text *aiwire.RespText) *aiwire.ResponseFormat { func usageFromResp(u *aiwire.RespUsage) *aiwire.Usage {
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 ""
} }
-199
View File
@@ -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))
}

Some files were not shown because too many files have changed in this diff Show More